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) {
100100 return `'${value.replaceAll("'", "'\\''")}'`;
101101}
102102
103function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) {
104 const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs");
103export function makeDevelopmentLauncherScript({
104 electronBinaryPath,
105 mainEntryPath,
106 desktopRoot,
107 environment,
108}) {
105109 const envEntries = [
106 ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL],
107 ["T3CODE_PORT", process.env.T3CODE_PORT],
108 ["T3CODE_HOME", process.env.T3CODE_HOME],
109 ["T3CODE_COMMIT_HASH", process.env.T3CODE_COMMIT_HASH],
110 ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL],
111 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS],
110 ["VITE_DEV_SERVER_URL", environment.VITE_DEV_SERVER_URL],
111 ["T3CODE_PORT", environment.T3CODE_PORT],
112 ["T3CODE_HOME", environment.T3CODE_HOME],
113 ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH],
114 ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL],
115 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS],
112116 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],
113117 ].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) {
114130 NodeFS.writeFileSync(
115131 targetBinaryPath,
116 [
117 "#!/bin/sh",
118 ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`),
119 `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`,
120 "",
121 ].join("\n"),
132 makeDevelopmentLauncherScript({
133 electronBinaryPath,
134 mainEntryPath: NodePath.join(desktopDir, "dist-electron", "main.cjs"),
135 desktopRoot: desktopDir,
136 environment: process.env,
137 }),
122138 );
123139 NodeFS.chmodSync(targetBinaryPath, 0o755);
124140}
......@@ -286,6 +302,12 @@ function buildMacLauncher(electronBinaryPath) {
286302 currentMetadata &&
287303 JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)
288304 ) {
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 }
289311 registerMacLauncherBundle(targetAppBundlePath);
290312 return targetBinaryPath;
291313 }
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";
1818import * as BrowserSession from "./BrowserSession.ts";
1919import * as PreviewManager from "./Manager.ts";
2020
21const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } =
22 vi.hoisted(() => ({
23 createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })),
24 fromId: vi.fn(() => null),
25 mkdir: vi.fn((_path: string) => undefined),
26 showItemInFolder: vi.fn(),
27 webviewSend: vi.fn(),
28 writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined),
29 writeImage: vi.fn(),
30 }));
21const {
22 createFromPath,
23 fromId,
24 getFocusedWebContents,
25 mkdir,
26 showItemInFolder,
27 webviewSend,
28 writeFile,
29 writeImage,
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
3241vi.mock("electron", () => ({
3342 clipboard: {
......@@ -44,6 +53,7 @@ vi.mock("electron", () => ({
4453 },
4554 webContents: {
4655 fromId,
56 getFocusedWebContents,
4757 },
4858}));
4959
......@@ -97,6 +107,8 @@ const withManager = <A>(
97107describe("PreviewManager", () => {
98108 beforeEach(() => {
99109 fromId.mockClear();
110 getFocusedWebContents.mockReset();
111 getFocusedWebContents.mockReturnValue(null);
100112 mkdir.mockClear();
101113 writeFile.mockClear();
102114 showItemInFolder.mockClear();
......@@ -250,6 +262,197 @@ describe("PreviewManager", () => {
250262 ),
251263 );
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
253456 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>
254457 withManager((manager) =>
255458 Effect.gen(function* () {
......@@ -511,6 +714,178 @@ describe("PreviewManager", () => {
511714 ),
512715 );
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
514889 effectIt.effect("still interrupts agent control for a different human pointer event", () =>
515890 withManager((manager) =>
516891 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+232-74
......@@ -62,6 +62,7 @@ import {
6262} from "./GuestProtocol.ts";
6363import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
6464import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
65import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";
6566
6667export type PreviewNavStatus =
6768 | { kind: "Idle" }
......@@ -854,11 +855,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
854855 commandParams?: Record<string, unknown>,
855856 ) => 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
857871 const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>(
858872 tabId: string,
859873 wc: Electron.WebContents,
860874 action: string,
861 use: (send: SendCommand) => Effect.Effect<A, PreviewManagerError>,
875 use: (send: SendCommand, sendCleanup: SendCommand) => Effect.Effect<A, PreviewManagerError>,
862876 ) {
863877 const sequence = yield* nextCounter(actionSequenceRef);
864878 const startedAt = yield* currentIso;
......@@ -899,7 +913,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
899913 return result;
900914 },
901915 );
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);
903932 });
904933 const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* (
905934 exit: Exit.Exit<A, PreviewManagerError>,
......@@ -1091,22 +1120,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
10911120 wc: Electron.WebContents,
10921121 ) {
10931122 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 ) {
10951126 if (wc.isDestroyed()) return;
1096 yield* update(tabId, {
1097 navStatus: computeNavStatus(wc),
1098 canGoBack: wc.navigationHistory.canGoBack(),
1099 canGoForward: wc.navigationHistory.canGoForward(),
1127 const zoomFactor = yield* attempt(
1128 { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id },
1129 () => wc.getZoomFactor(),
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;
11001161 });
1162 if (Option.isSome(next)) yield* emit(tabId, next.value);
11011163 });
1102 const sync = () => runFork(syncState());
1103 const failed = (_event: Event, code: number, description: string): void => {
1104 if (code === -3) return;
1164 const sync = () => runFork(syncState(true));
1165 const syncNavigation = () => runFork(syncState(false));
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;
11051174 runFork(
11061175 update(tabId, {
11071176 navStatus: {
11081177 kind: "LoadFailed",
1109 url: wc.getURL(),
1178 url: validatedUrl || wc.getURL(),
11101179 title: wc.getTitle(),
11111180 code,
11121181 description,
......@@ -1161,8 +1230,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
11611230 yield* Scope.addFinalizer(
11621231 scope,
11631232 attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {
1164 wc.off("did-navigate", sync);
1165 wc.off("did-navigate-in-page", sync);
1233 wc.off("did-navigate", syncNavigation);
1234 wc.off("did-navigate-in-page", syncNavigation);
11661235 wc.off("page-title-updated", sync);
11671236 wc.off("did-start-loading", sync);
11681237 wc.off("did-stop-loading", sync);
......@@ -1173,8 +1242,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
11731242 );
11741243 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
11751244 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
1176 wc.on("did-navigate", sync);
1177 wc.on("did-navigate-in-page", sync);
1245 wc.on("did-navigate", syncNavigation);
1246 wc.on("did-navigate-in-page", syncNavigation);
11781247 wc.on("page-title-updated", sync);
11791248 wc.on("did-start-loading", sync);
11801249 wc.on("did-stop-loading", sync);
......@@ -1280,21 +1349,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12801349 const attached = yield* Ref.get(attachedRef);
12811350 const annotationTheme = yield* Ref.get(annotationThemeRef);
12821351 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 });
12831357 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
12841358 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
12851359 );
12861360 return;
12871361 }
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) {
12891365 yield* Effect.all(
12901366 [
1291 detachControlSession(tab.webContentsId),
1292 detachListeners(tab.webContentsId),
1367 detachControlSession(replacedWebContentsId),
1368 detachListeners(replacedWebContentsId),
12931369 cancelPickElement(tabId),
12941370 ],
12951371 { concurrency: 3, discard: true },
12961372 );
12971373 }
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 );
12981386 yield* attachListeners(tabId, wc);
12991387 runFork(ensureControlSession(wc).pipe(Effect.ignore));
13001388 const registeredAt = yield* currentIso;
......@@ -1313,6 +1401,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13131401 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
13141402 canGoBack: wc.navigationHistory.canGoBack(),
13151403 canGoForward: wc.navigationHistory.canGoForward(),
1404 zoomFactor,
13161405 updatedAt: registeredAt,
13171406 };
13181407 return [
......@@ -1330,11 +1419,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13301419 }
13311420 const { state: registered, pendingUrl } = registration.value;
13321421 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 }
13381422 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
13391423 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
13401424 );
......@@ -1946,10 +2030,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
19462030 input: PreviewAutomationClickInput,
19472031 send: SendCommand,
19482032 ) {
1949 yield* Effect.all(
1950 [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })],
1951 { concurrency: 2, discard: true },
1952 );
2033 yield* prepareAutomationInput(send, true);
19532034 const point = yield* resolveClickPoint(tabId, send, input);
19542035 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(
19552036 tabId,
......@@ -2011,7 +2092,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20112092 );
20122093 });
20132094
2014 const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* (
2095 const typeIntoAutomationTarget = Effect.fn("PreviewManager.typeIntoAutomationTarget")(function* (
20152096 tabId: string,
20162097 send: SendCommand,
20172098 input: PreviewAutomationTypeInput,
......@@ -2021,8 +2102,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20212102 const locatorJson = locator
20222103 ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator)
20232104 : null;
2105 const textJson = yield* encodeJson(
2106 { operation: "automationType.encodeText", tabId },
2107 input.text,
2108 );
20242109 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 }
20262114 >(
20272115 tabId,
20282116 send,
......@@ -2030,12 +2118,54 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20302118 try {
20312119 const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"};
20322120 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 };
20332127 element.focus();
2034 if (${input.clear ?? false}) {
2035 if ("value" in element) element.value = "";
2036 else if (element.isContentEditable) element.textContent = "";
2037 element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));
2128 if (document.activeElement !== element) return { notEditable: true };
2129 const clear = ${input.clear ?? false};
2130 if (clear) {
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 }
20382166 }
2167 if (!inserted) return { notEditable: true };
2168 element.dispatchEvent(new Event("change", { bubbles: true }));
20392169 return { ok: true };
20402170 } catch (error) {
20412171 return { invalidSelector: true, message: String(error) };
......@@ -2059,6 +2189,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20592189 ...automationSelectorDiagnostics(input),
20602190 });
20612191 }
2192 if ("notEditable" in result) {
2193 return yield* new PreviewAutomationTargetNotEditableError({
2194 tabId,
2195 ...automationSelectorDiagnostics(input),
2196 });
2197 }
20622198 });
20632199
20642200 const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* (
......@@ -2066,23 +2202,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20662202 input: PreviewAutomationTypeInput,
20672203 send: SendCommand,
20682204 ) {
2069 yield* send("Runtime.enable");
2070 yield* focusAutomationTarget(tabId, send, input);
2071 yield* send("Input.insertText", { text: input.text });
2072 const textJson = yield* encodeJson(
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 );
2205 // CDP Input.insertText silently drops text until Electron has activated a hidden
2206 // guest WebContents with a pointer event. Editing in the page runtime keeps
2207 // background automation deterministic without stealing foreground app focus.
2208 yield* typeIntoAutomationTarget(tabId, send, input);
20862209 });
20872210
20882211 const automationType = Effect.fn("PreviewManager.automationType")(function* (
......@@ -2097,32 +2220,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20972220
20982221 const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* (
20992222 tabId: string,
2223 wc: Electron.WebContents,
21002224 input: PreviewAutomationPressInput,
21012225 send: SendCommand,
2226 sendCleanup: SendCommand,
21022227 ) {
2103 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
2104 switch (modifier) {
2105 case "Alt":
2106 return value | 1;
2107 case "Control":
2108 return value | 2;
2109 case "Meta":
2110 return value | 4;
2111 case "Shift":
2112 return value | 8;
2228 yield* prepareAutomationInput(send, false);
2229 const keySequence = makePreviewAutomationKeySequence(input);
2230 const previouslyFocused = yield* attempt(
2231 { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id },
2232 () => webContents.getFocusedWebContents(),
2233 );
2234 let keyDownAttempted = false;
2235 const releaseInput = Effect.gen(function* () {
2236 if (keyDownAttempted) {
2237 yield* sendCleanup("Input.dispatchKeyEvent", keySequence.keyUp).pipe(Effect.ignore);
21132238 }
2114 }, 0);
2115 const key = input.key;
2116 const text = key.length === 1 ? key : undefined;
2117 const params = {
2118 key,
2119 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
2120 modifiers,
2121 ...(text ? { text, unmodifiedText: text } : {}),
2122 };
2123 yield* expectAgentInput(tabId, { kind: "key", key, code: params.code });
2124 yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
2125 yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
2239 yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe(
2240 Effect.ignore,
2241 );
2242 if (previouslyFocused && previouslyFocused.id !== wc.id && !previouslyFocused.isDestroyed()) {
2243 yield* attempt(
2244 {
2245 operation: "automationPress.restoreFocusedWebContents",
2246 tabId,
2247 webContentsId: previouslyFocused.id,
2248 },
2249 () => previouslyFocused.focus(),
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));
21262269 });
21272270
21282271 const automationPress = Effect.fn("PreviewManager.automationPress")(function* (
......@@ -2130,8 +2273,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
21302273 input: PreviewAutomationPressInput,
21312274 ) {
21322275 const wc = yield* requireWebContents(tabId);
2133 yield* withControlSession(tabId, wc, "press", (send) =>
2134 performAutomationPress(tabId, input, send),
2276 yield* withControlSession(tabId, wc, "press", (send, sendCleanup) =>
2277 performAutomationPress(tabId, wc, input, send, sendCleanup),
21352278 );
21362279 });
21372280
......@@ -2529,6 +2672,20 @@ export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClas
25292672 }
25302673}
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
25322689export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass<PreviewAutomationCoordinatesOutsideViewportError>()(
25332690 "PreviewAutomationCoordinatesOutsideViewportError",
25342691 {
......@@ -2631,6 +2788,7 @@ export const PreviewManagerError = Schema.Union([
26312788 PreviewAutomationDebuggerAttachedError,
26322789 PreviewAutomationEvaluationError,
26332790 PreviewAutomationTargetNotFoundError,
2791 PreviewAutomationTargetNotEditableError,
26342792 PreviewAutomationCoordinatesOutsideViewportError,
26352793 PreviewAutomationInvalidSelectorError,
26362794 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";
44import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
66import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";
78
89import type * as Electron from "electron";
910import { vi } from "vite-plus/test";
......@@ -47,12 +48,14 @@ function makeFakeBrowserWindow() {
4748 const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>();
4849 const webContents = {
4950 copyImageAt: vi.fn(),
51 getURL: vi.fn(() => "t3code-dev://app/"),
5052 isLoadingMainFrame: vi.fn(() => false),
5153 on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {
5254 webContentsListeners.set(eventName, listener);
5355 }),
5456 once: vi.fn(),
5557 openDevTools: vi.fn(),
58 reload: vi.fn(),
5659 replaceMisspelling: vi.fn(),
5760 send: vi.fn(),
5861 setWindowOpenHandler: vi.fn(),
......@@ -79,6 +82,7 @@ function makeFakeBrowserWindow() {
7982 window: window as unknown as Electron.BrowserWindow,
8083 loadURL: window.loadURL,
8184 openDevTools: webContents.openDevTools,
85 reload: webContents.reload,
8286 setAutoHideCursor: window.setAutoHideCursor,
8387 webContentsListeners,
8488 };
......@@ -237,6 +241,73 @@ describe("DesktopWindow", () => {
237241 }),
238242 );
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
240311 it.effect("opens safe off-origin renderer navigations in the system browser", () =>
241312 Effect.gen(function* () {
242313 const fakeWindow = makeFakeBrowserWindow();
apps/desktop/src/window/DesktopWindow.ts+95-3
......@@ -1,5 +1,6 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
34import * as Layer from "effect/Layer";
45import * as Option from "effect/Option";
56import * as Ref from "effect/Ref";
......@@ -22,6 +23,16 @@ const TITLEBAR_HEIGHT = 40;
2223const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
2324const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
2425const 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
2637type WindowTitleBarOptions = Pick<
2738 Electron.BrowserWindowConstructorOptions,
......@@ -86,6 +97,22 @@ export function isSameOriginRendererNavigation(input: {
8697 }
8798}
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
89116function getWindowTitleBarOptions(
90117 shouldUseDarkColors: boolean,
91118 platform: NodeJS.Platform,
......@@ -152,6 +179,7 @@ export const make = Effect.gen(function* () {
152179 const previewManager = yield* PreviewManager.PreviewManager;
153180 const state = yield* DesktopState.DesktopState;
154181 const context = yield* Effect.context<DesktopWindowRuntimeServices>();
182 const runFork = Effect.runForkWith(context);
155183 const runPromise = Effect.runPromiseWith(context);
156184
157185 const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return<
......@@ -277,7 +305,61 @@ export const make = Effect.gen(function* () {
277305 event.preventDefault();
278306 window.setTitle(environment.displayName);
279307 });
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
280351 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;
281363 window.setTitle(environment.displayName);
282364 });
283365 window.webContents.on(
......@@ -286,11 +368,22 @@ export const make = Effect.gen(function* () {
286368 if (!isMainFrame) {
287369 return;
288370 }
371 const retryInMs =
372 environment.isDevelopment &&
373 isRetryableDevelopmentRendererLoadFailure({
374 applicationUrl,
375 errorCode,
376 isMainFrame,
377 validatedUrl: validatedURL,
378 })
379 ? scheduleDevelopmentLoadRetry()
380 : undefined;
289381 void runPromise(
290382 logWindowWarning("main window failed to load", {
291383 errorCode,
292384 errorDescription,
293385 url: validatedURL,
386 ...(retryInMs === undefined ? {} : { retryInMs }),
294387 }),
295388 );
296389 },
......@@ -312,14 +405,13 @@ export const make = Effect.gen(function* () {
312405 void runPromise(electronWindow.reveal(window));
313406 });
314407
408 loadApplication();
315409 if (environment.isDevelopment) {
316 void window.loadURL(applicationUrl);
317410 window.webContents.openDevTools({ mode: "detach" });
318 } else {
319 void window.loadURL(applicationUrl);
320411 }
321412
322413 window.on("closed", () => {
414 clearDevelopmentLoadRetry();
323415 void runPromise(electronWindow.clearMain(Option.some(window)));
324416 });
325417
apps/server/src/mcp/McpHttpServer.test.ts+56-75
......@@ -1,5 +1,6 @@
11import { expect, it } from "@effect/vitest";
22import { NodeHttpServer } from "@effect/platform-node";
3import * as NodeServices from "@effect/platform-node/NodeServices";
34import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
45import * as Effect from "effect/Effect";
56import * as Layer from "effect/Layer";
......@@ -34,7 +35,7 @@ const client = McpSchema.McpServerClient.of({
3435});
3536const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
3637 Layer.provideMerge(McpServer.McpServer.layer),
37 Layer.provideMerge(PreviewAutomationBroker.layer),
38 Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))),
3839);
3940
4041it("normalizes empty successful notification responses to accepted", () => {
......@@ -54,36 +55,26 @@ it.effect("returns bounded structural preview snapshot failures", () =>
5455 Effect.gen(function* () {
5556 const server = yield* McpServer.McpServer;
5657 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
57 const requests = yield* broker.connect({
58 const events = yield* broker.connect({
5859 clientId: "mcp-failure-client",
5960 environmentId,
60 threadId,
61 tabId,
62 visible: true,
63 supportsAutomation: true,
64 focusedAt: "2026-06-11T00:00:00.000Z",
6561 });
66 yield* Stream.runForEach(requests, (request) =>
67 broker.respond({
68 requestId: request.requestId,
69 ok: false,
70 error: {
71 _tag: "PreviewAutomationExecutionError",
72 message: "sensitive renderer failure",
73 detail: { consoleOutput: "sensitive browser output" },
74 },
75 }),
62 yield* Stream.runForEach(events, (event) =>
63 event.type === "connected"
64 ? Effect.void
65 : broker.respond({
66 clientId: "mcp-failure-client",
67 connectionId: event.connectionId,
68 requestId: event.request.requestId,
69 ok: false,
70 error: {
71 _tag: "PreviewAutomationExecutionError",
72 message: "sensitive renderer failure",
73 detail: { consoleOutput: "sensitive browser output" },
74 },
75 }),
7676 ).pipe(Effect.forkScoped);
7777 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
8879 const snapshot = yield* server
8980 .callTool({ name: "preview_snapshot", arguments: {} })
......@@ -163,60 +154,50 @@ it.effect("registers annotated tools and preserves authenticated request context
163154 Effect.gen(function* () {
164155 const server = yield* McpServer.McpServer;
165156 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
166 const requests = yield* broker.connect({
157 const events = yield* broker.connect({
167158 clientId: "mcp-test-client",
168159 environmentId,
169 threadId,
170 tabId,
171 visible: true,
172 supportsAutomation: true,
173 focusedAt: "2026-06-11T00:00:00.000Z",
174160 });
175 yield* Stream.runForEach(requests, (request) =>
176 broker.respond({
177 requestId: request.requestId,
178 ok: true,
179 result:
180 request.operation === "snapshot"
181 ? {
182 url: "http://example.test/",
183 title: "Example",
184 loading: false,
185 visibleText: "Example",
186 interactiveElements: [],
187 accessibilityTree: {},
188 consoleEntries: [],
189 networkEntries: [],
190 actionTimeline: [],
191 screenshot: {
192 mimeType: "image/png",
193 data: Buffer.from("png").toString("base64"),
194 width: 10,
195 height: 5,
196 },
197 }
198 : request.operation === "press"
199 ? undefined
200 : {
201 available: true,
202 visible: true,
203 tabId,
204 url: "http://example.test/",
205 title: "Example",
206 loading: false,
207 },
208 }),
161 yield* Stream.runForEach(events, (event) =>
162 event.type === "connected"
163 ? Effect.void
164 : broker.respond({
165 clientId: "mcp-test-client",
166 connectionId: event.connectionId,
167 requestId: event.request.requestId,
168 ok: true,
169 result:
170 event.request.operation === "snapshot"
171 ? {
172 url: "http://example.test/",
173 title: "Example",
174 loading: false,
175 visibleText: "Example",
176 interactiveElements: [],
177 accessibilityTree: {},
178 consoleEntries: [],
179 networkEntries: [],
180 actionTimeline: [],
181 screenshot: {
182 mimeType: "image/png",
183 data: Buffer.from("png").toString("base64"),
184 width: 10,
185 height: 5,
186 },
187 }
188 : event.request.operation === "press"
189 ? undefined
190 : {
191 available: true,
192 visible: true,
193 tabId,
194 url: "http://example.test/",
195 title: "Example",
196 loading: false,
197 },
198 }),
209199 ).pipe(Effect.forkScoped);
210200 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
221202 const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");
222203 expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true);
apps/server/src/mcp/McpHttpServer.ts+1-4
......@@ -214,7 +214,4 @@ const McpTransportLive = McpServer.layerHttp({
214214 path: "/mcp",
215215}).pipe(Layer.provide(McpAuthMiddlewareLive));
216216
217export const layer = PreviewToolkitRegistrationLive.pipe(
218 Layer.provideMerge(McpTransportLive),
219 Layer.provide(PreviewAutomationBroker.layer),
220);
217export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive));
apps/server/src/mcp/PreviewAutomationBroker.test.ts+584-74
......@@ -1,20 +1,28 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
12import { expect, it } from "@effect/vitest";
23import {
34 EnvironmentId,
45 PreviewAutomationClientDisconnectedError,
56 PreviewAutomationInvalidSelectorError,
67 PreviewAutomationMalformedResponseError,
7 PreviewAutomationNoFocusedOwnerError,
8 PreviewAutomationNoAvailableHostError,
9 PreviewAutomationTargetNotEditableError,
10 PreviewTabId,
811 ProviderInstanceId,
912 ThreadId,
10 type PreviewAutomationOwner,
13 type PreviewAutomationHost,
14 type PreviewAutomationRequest,
15 type PreviewAutomationStreamEvent,
1116} from "@t3tools/contracts";
1217import * as Effect from "effect/Effect";
1318import * as Fiber from "effect/Fiber";
19import * as Result from "effect/Result";
1420import * as Stream from "effect/Stream";
1521
1622import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
1723
24const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer));
25
1826const scope = {
1927 environmentId: EnvironmentId.make("environment-1"),
2028 threadId: ThreadId.make("thread-1"),
......@@ -22,27 +30,42 @@ const scope = {
2230 providerInstanceId: ProviderInstanceId.make("codex"),
2331 capabilities: new Set(["preview"] as const),
2432 issuedAt: 1,
25 expiresAt: 2,
33 expiresAt: Number.MAX_SAFE_INTEGER,
2634};
2735
28const makeOwner = (overrides: Partial<PreviewAutomationOwner> = {}): PreviewAutomationOwner => ({
36const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({
2937 clientId: "client-1",
3038 environmentId: scope.environmentId,
31 threadId: scope.threadId,
32 tabId: null,
33 visible: false,
34 supportsAutomation: true,
35 focusedAt: "2026-06-11T00:00:00.000Z",
3639 ...overrides,
3740});
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", () =>
4061 Effect.scoped(
4162 Effect.gen(function* () {
42 const broker = yield* PreviewAutomationBroker.make;
43 const requests = yield* broker.connect(makeOwner());
63 const broker = yield* makeBroker;
64 const requests = requestsFrom(yield* broker.connect(makeHost()));
4465 yield* Stream.runForEach(requests, (request) =>
4566 broker.respond({
67 clientId: "client-1",
68 connectionId: request.connectionId,
4669 requestId: request.requestId,
4770 ok: true,
4871 result: { available: true },
......@@ -61,6 +84,39 @@ it.effect("atomically registers a connected owner and correlates its response",
6184 ),
6285);
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
64120it.effect("preserves bounded request and remote selector diagnostics", () => {
65121 const locator = "role=button[name='request-secret']";
66122 const remoteMessage = "Unexpected token near remote-secret.";
......@@ -72,10 +128,12 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {
72128
73129 return Effect.scoped(
74130 Effect.gen(function* () {
75 const broker = yield* PreviewAutomationBroker.make;
76 const requests = yield* broker.connect(makeOwner({ tabId: "tab-1" }));
131 const broker = yield* makeBroker;
132 const requests = requestsFrom(yield* broker.connect(makeHost()));
77133 yield* Stream.runForEach(requests, (request) =>
78134 broker.respond({
135 clientId: "client-1",
136 connectionId: request.connectionId,
79137 requestId: request.requestId,
80138 ok: false,
81139 error: remoteError,
......@@ -88,6 +146,7 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {
88146 scope,
89147 operation: "click",
90148 input: { locator },
149 tabId: PreviewTabId.make("tab-1"),
91150 timeoutMs: 1_234,
92151 })
93152 .pipe(Effect.flip);
......@@ -121,13 +180,61 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {
121180 );
122181});
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
124226it.effect("distinguishes malformed remote failures", () =>
125227 Effect.scoped(
126228 Effect.gen(function* () {
127 const broker = yield* PreviewAutomationBroker.make;
128 const requests = yield* broker.connect(makeOwner());
229 const broker = yield* makeBroker;
230 const requests = requestsFrom(yield* broker.connect(makeHost()));
129231 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 }),
131238 ).pipe(Effect.forkScoped);
132239 yield* Effect.yieldNow;
133240
......@@ -150,13 +257,14 @@ it.effect("distinguishes malformed remote failures", () =>
150257 ),
151258);
152259
153it.effect("rejects calls when no focused owner exists", () =>
260it.effect("rejects calls when no connected host exists", () =>
154261 Effect.gen(function* () {
155 const broker = yield* PreviewAutomationBroker.make;
262 const broker = yield* makeBroker;
156263 const error = yield* broker
157264 .invoke<void>({ scope, operation: "status", input: {} })
158265 .pipe(Effect.flip);
159 expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError);
266
267 expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
160268 expect(error).toMatchObject({
161269 operation: "status",
162270 environmentId: scope.environmentId,
......@@ -167,97 +275,484 @@ it.effect("rejects calls when no focused owner exists", () =>
167275 }),
168276);
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", () =>
171296 Effect.scoped(
172297 Effect.gen(function* () {
173 const broker = yield* PreviewAutomationBroker.make;
174 const requests = yield* broker.connect(
175 makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }),
176 );
177 yield* Stream.runForEach(requests, (request) =>
178 broker.respond({ requestId: request.requestId, ok: true }),
179 ).pipe(Effect.forkScoped);
298 const broker = yield* makeBroker;
299 const requests = requestsFrom(yield* broker.connect(makeHost()));
300 const beforeAcquisition = yield* broker
301 .invoke<void>({ scope, operation: "status", input: {} })
302 .pipe(Effect.flip);
303 expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
304
305 const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped);
180306 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);
183313 }),
184314 ),
185315);
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", () =>
188318 Effect.scoped(
189319 Effect.gen(function* () {
190 const broker = yield* PreviewAutomationBroker.make;
191 const requests = yield* broker.connect(makeOwner({ tabId: null }));
192 let routedTabId: string | undefined;
320 const broker = yield* makeBroker;
321 const backgroundThreadId = ThreadId.make("thread-background");
322 const requests = requestsFrom(yield* broker.connect(makeHost()));
323 let routedThreadId: string | undefined;
193324 yield* Stream.runForEach(requests, (request) => {
194 routedTabId = request.tabId;
195 return broker.respond({ requestId: request.requestId, ok: true });
325 routedThreadId = request.threadId;
326 return broker.respond({
327 clientId: "client-1",
328 connectionId: request.connectionId,
329 requestId: request.requestId,
330 ok: true,
331 result: "background",
332 });
196333 }).pipe(Effect.forkScoped);
197334 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);
202348 }),
203349 ),
204350);
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", () =>
207353 Effect.scoped(
208354 Effect.gen(function* () {
209 const broker = yield* PreviewAutomationBroker.make;
210 const firstRequests = yield* broker.connect(makeOwner());
211 yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped);
212 yield* broker.reportOwner(makeOwner({ tabId: "tab-current", visible: true }));
355 const broker = yield* makeBroker;
356 const matchingRequests = requestsFrom(
357 yield* broker.connect(makeHost({ clientId: "client-matching" })),
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()));
215688 let routedTabId: string | undefined;
216 yield* Stream.runForEach(reconnectedRequests, (request) => {
689 yield* Stream.runForEach(requests, (request) => {
217690 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 });
219697 }).pipe(Effect.forkScoped);
220698 yield* Effect.yieldNow;
221699
222700 yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } });
223701
224 expect(routedTabId).toBe("tab-current");
702 expect(routedTabId).toBeUndefined();
225703 }),
226704 ),
227705);
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", () =>
230708 Effect.scoped(
231709 Effect.gen(function* () {
232 const broker = yield* PreviewAutomationBroker.make;
233 const requests = yield* broker.connect(makeOwner());
234 yield* Stream.runForEach(requests, (request) =>
235 broker.respond({ requestId: request.requestId, ok: true }),
236 ).pipe(Effect.forkScoped);
710 const broker = yield* makeBroker;
711 let firstConnectionId = "";
712 let replacementConnectionId = "";
713 const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => {
714 firstConnectionId = connectionId;
715 });
716 yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped);
237717 yield* Effect.yieldNow;
238718
239 yield* broker.clearOwner({
240 clientId: "client-1",
241 environmentId: scope.environmentId,
242 threadId: ThreadId.make("thread-stale"),
243 });
719 const replacementRequests = requestsFrom(
720 yield* broker.connect(makeHost()),
721 (connectionId) => {
722 replacementConnectionId = connectionId;
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");
246739 }),
247740 ),
248741);
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", () =>
251744 Effect.scoped(
252745 Effect.gen(function* () {
253 const broker = yield* PreviewAutomationBroker.make;
254 const _requests = yield* broker.connect(makeOwner());
746 const broker = yield* makeBroker;
747 const requests = requestsFrom(yield* broker.connect(makeHost()));
748 yield* Stream.runDrain(requests).pipe(Effect.forkScoped);
255749 const pending = yield* broker
256750 .invoke<void>({ scope, operation: "status", input: {} })
257751 .pipe(Effect.flip, Effect.forkScoped);
258752 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
262757 const error = yield* Fiber.join(pending);
263758 expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError);
......@@ -275,25 +770,40 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec
275770 ),
276771);
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", () =>
279774 Effect.scoped(
280775 Effect.gen(function* () {
281 const broker = yield* PreviewAutomationBroker.make;
282 const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" }));
776 const broker = yield* makeBroker;
777 const requests = requestsFrom(yield* broker.connect(makeHost()));
283778 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 }),
285802 ).pipe(Effect.forkScoped);
286803 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
294805 const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} });
295
296 expect(result).toBe("connected");
806 expect(result).toBe("owner");
297807 }),
298808 ),
299809);
apps/server/src/mcp/PreviewAutomationBroker.ts+254-129
......@@ -1,26 +1,29 @@
11import {
2 PREVIEW_AUTOMATION_V1_OPERATIONS,
23 PreviewAutomationClientDisconnectedError,
34 PreviewAutomationControlInterruptedError,
45 PreviewAutomationExecutionError,
5 PreviewAutomationHostNotConnectedError,
66 PreviewAutomationInvalidSelectorError,
77 PreviewAutomationMalformedResponseError,
8 PreviewAutomationNoFocusedOwnerError,
8 PreviewAutomationNoAvailableHostError,
99 PreviewAutomationRemoteUnavailableError,
1010 PreviewAutomationRequestQueueClosedError,
1111 PreviewAutomationResultTooLargeError,
1212 PreviewAutomationTabNotFoundError,
13 PreviewAutomationTargetNotEditableError,
1314 PreviewAutomationTimeoutError,
1415 PreviewAutomationUnsupportedClientError,
1516 type PreviewAutomationError,
1617 type PreviewAutomationOperation,
17 type PreviewAutomationOwner,
18 type PreviewAutomationOwnerIdentity,
19 type PreviewAutomationRequest,
18 type PreviewAutomationHost,
19 type PreviewAutomationHostFocus,
2020 type PreviewAutomationResponse,
21 type PreviewAutomationStreamEvent,
2122 type PreviewTabId,
2223} from "@t3tools/contracts";
2324import * as Context from "effect/Context";
25import * as Clock from "effect/Clock";
26import * as Crypto from "effect/Crypto";
2427import * as Deferred from "effect/Deferred";
2528import * as Effect from "effect/Effect";
2629import * as Layer from "effect/Layer";
......@@ -43,12 +46,9 @@ export class PreviewAutomationBroker extends Context.Service<
4346 PreviewAutomationBroker,
4447 {
4548 readonly connect: (
46 owner: PreviewAutomationOwner,
47 ) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>;
48 readonly reportOwner: (
49 owner: PreviewAutomationOwner,
50 ) => Effect.Effect<void, PreviewAutomationError>;
51 readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect<void>;
49 host: PreviewAutomationHost,
50 ) => Effect.Effect<Stream.Stream<PreviewAutomationStreamEvent>>;
51 readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect<void>;
5252 readonly respond: (
5353 response: PreviewAutomationResponse,
5454 ) => Effect.Effect<void, PreviewAutomationError>;
......@@ -60,7 +60,12 @@ export class PreviewAutomationBroker extends Context.Service<
6060
6161interface ClientConnection {
6262 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>;
6469}
6570
6671interface PendingRequest {
......@@ -69,6 +74,13 @@ interface PendingRequest {
6974 readonly context: PreviewAutomationRequestErrorContext;
7075}
7176
77interface HostAssignment {
78 readonly clientId: ClientConnection["clientId"];
79 readonly connectionId: ClientConnection["connectionId"];
80 readonly queue: ClientConnection["queue"];
81 readonly expiresAt: number;
82}
83
7284interface PreviewAutomationRequestErrorContext {
7385 readonly operation: PreviewAutomationOperation;
7486 readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"];
......@@ -76,6 +88,7 @@ interface PreviewAutomationRequestErrorContext {
7688 readonly providerSessionId: string;
7789 readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"];
7890 readonly clientId: string;
91 readonly connectionId: ClientConnection["connectionId"];
7992 readonly requestId: string;
8093 readonly tabId?: PreviewTabId;
8194 readonly timeoutMs: number;
......@@ -85,11 +98,36 @@ interface PreviewAutomationRequestErrorContext {
8598
8699interface BrokerState {
87100 readonly clients: ReadonlyMap<string, ClientConnection>;
88 readonly owners: ReadonlyMap<string, PreviewAutomationOwner>;
101 readonly assignments: ReadonlyMap<string, HostAssignment>;
89102 readonly pending: ReadonlyMap<string, PendingRequest>;
90103 readonly requestSequence: number;
104 readonly focusSequence: number;
91105}
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
93131const selectorDiagnosticsFromInput = (
94132 input: unknown,
95133): Pick<PreviewAutomationRequestErrorContext, "selectorKind" | "selectorLength"> => {
......@@ -103,6 +141,14 @@ const selectorDiagnosticsFromInput = (
103141 return {};
104142};
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
106152type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean";
107153
108154function remoteDetailKind(detail: unknown): RemoteDetailKind {
......@@ -131,8 +177,8 @@ const classifyResponseError = (
131177 cause: error,
132178 };
133179 switch (error._tag) {
134 case "PreviewAutomationNoFocusedOwnerError":
135 return new PreviewAutomationNoFocusedOwnerError({
180 case "PreviewAutomationNoAvailableHostError":
181 return new PreviewAutomationNoAvailableHostError({
136182 ...context,
137183 ...remoteDiagnostics,
138184 });
......@@ -162,6 +208,36 @@ const classifyResponseError = (
162208 ...remoteDiagnostics,
163209 });
164210 }
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 }
165241 case "PreviewAutomationResultTooLargeError": {
166242 const detail =
167243 typeof error.detail === "object" && error.detail !== null ? error.detail : undefined;
......@@ -193,36 +269,21 @@ const classifyResponseError = (
193269};
194270
195271export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
272 const crypto = yield* Crypto.Crypto;
196273 const state = yield* SynchronizedRef.make<BrokerState>({
197274 clients: new Map(),
198 owners: new Map(),
275 assignments: new Map(),
199276 pending: new Map(),
200277 requestSequence: 0,
278 focusSequence: 0,
201279 });
202280
203 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
204 clientId: string,
281 const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* (
205282 queue: ClientConnection["queue"],
283 disconnected: ReadonlyArray<PendingRequest>,
206284 ) {
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 });
224285 yield* Effect.forEach(
225 toFail,
286 disconnected,
226287 ({ deferred, context }) =>
227288 Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)),
228289 { discard: true },
......@@ -230,54 +291,89 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
230291 yield* Queue.shutdown(queue);
231292 });
232293
233 const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn(
234 "PreviewAutomationBroker.connect",
235 )(function* (owner) {
236 const clientId = owner.clientId;
237 const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>();
238 const previous = yield* SynchronizedRef.modify(state, (current) => {
239 const clients = new Map(current.clients);
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;
294 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
295 clientId: string,
296 queue: ClientConnection["queue"],
297 ) {
298 const disconnected = yield* SynchronizedRef.modify(state, (current) => {
299 const removed = removeConnectionFromState(current, clientId, queue);
300 return [removed.disconnected, removed.state] as const;
251301 });
252 if (previous) yield* disconnect(clientId, previous.queue);
253 return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue)));
302 yield* closeConnection(queue, disconnected);
254303 });
255304
256 const reportOwner: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn(
257 "PreviewAutomationBroker.reportOwner",
258 )(function* (owner) {
259 yield* SynchronizedRef.update(state, (current) => {
260 const owners = new Map(current.owners);
261 owners.set(owner.clientId, owner);
262 return { ...current, owners };
305 const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* (
306 host: PreviewAutomationHost,
307 ) {
308 const clientId = host.clientId;
309 const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent>();
310 const connectionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
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;
263338 });
339 if (registration.previousConnection) {
340 yield* closeConnection(registration.previousConnection.queue, registration.disconnected);
341 }
342 return registration.registeredConnection;
264343 });
265344
266 const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn(
267 "PreviewAutomationBroker.clearOwner",
268 )(function* (owner) {
345 const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn(
346 "PreviewAutomationBroker.connect",
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) {
269360 yield* SynchronizedRef.update(state, (current) => {
270 const currentOwner = current.owners.get(owner.clientId);
361 const currentHost = current.clients.get(host.clientId);
271362 if (
272 !currentOwner ||
273 currentOwner.environmentId !== owner.environmentId ||
274 currentOwner.threadId !== owner.threadId
363 !currentHost ||
364 currentHost.environmentId !== host.environmentId ||
365 currentHost.connectionId !== host.connectionId
275366 ) {
276367 return current;
277368 }
278 const owners = new Map(current.owners);
279 owners.delete(owner.clientId);
280 return { ...current, owners };
369 const clients = new Map(current.clients);
370 const focusSequence = host.focused ? current.focusSequence + 1 : current.focusSequence;
371 clients.set(host.clientId, {
372 ...currentHost,
373 focused: host.focused,
374 focusOrder: host.focused ? focusSequence : currentHost.focusOrder,
375 });
376 return { ...current, clients, focusSequence };
281377 });
282378 });
283379
......@@ -286,7 +382,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
286382 )(function* (response) {
287383 const pending = yield* SynchronizedRef.modify(state, (current) => {
288384 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 }
290392 const next = new Map(current.pending);
291393 next.delete(response.requestId);
292394 return [entry, { ...current, pending: next }] as const;
......@@ -307,52 +409,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
307409 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(
308410 input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0],
309411 ): 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 }
351412 const timeoutMs = input.timeoutMs ?? 15_000;
352413 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
353 const [requestId, requestContext] = yield* SynchronizedRef.modify(state, (next) => {
354 const requestId = `preview-${next.requestSequence}`;
355 const tabId = input.tabId ?? owner.tabId ?? undefined;
414 const now = yield* Clock.currentTimeMillis;
415 const route = yield* SynchronizedRef.modify(state, (current) => {
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;
356466 const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);
357467 const context: PreviewAutomationRequestErrorContext = {
358468 operation: input.operation,
......@@ -360,19 +470,30 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
360470 threadId: input.scope.threadId,
361471 providerSessionId: input.scope.providerSessionId,
362472 providerInstanceId: input.scope.providerInstanceId,
363 clientId: owner.clientId,
473 clientId: connection.clientId,
474 connectionId: connection.connectionId,
364475 requestId,
365476 ...(tabId === undefined ? {} : { tabId }),
366477 timeoutMs,
367478 ...selectorDiagnostics,
368479 };
369 const pending = new Map(next.pending);
480 const pending = new Map(current.pending);
370481 pending.set(requestId, { queue: connection.queue, deferred, context });
371482 return [
372 [requestId, context] as const,
373 { ...next, pending, requestSequence: next.requestSequence + 1 },
483 { connection, requestId, requestContext: context },
484 { ...current, assignments, pending, requestSequence: current.requestSequence + 1 },
374485 ] as const;
375486 });
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;
376497 const removePending = SynchronizedRef.update(state, (next) => {
377498 if (!next.pending.has(requestId)) return next;
378499 const pending = new Map(next.pending);
......@@ -381,12 +502,16 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
381502 });
382503 const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () {
383504 const offered = yield* Queue.offer(connection.queue, {
384 requestId,
385 threadId: input.scope.threadId,
386 tabId: requestContext.tabId,
387 operation: input.operation,
388 input: input.input,
389 timeoutMs,
505 type: "request",
506 connectionId: connection.connectionId,
507 request: {
508 requestId,
509 threadId: input.scope.threadId,
510 tabId: requestContext.tabId,
511 operation: input.operation,
512 input: input.input,
513 timeoutMs,
514 },
390515 });
391516 if (!offered) {
392517 const completion = yield* Deferred.poll(deferred);
......@@ -404,7 +529,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
404529 return yield* awaitResponse().pipe(Effect.ensuring(removePending));
405530 });
406531
407 return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke });
532 return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke });
408533}).pipe(Effect.withSpan("PreviewAutomationBroker.make"));
409534
410535export const layer = Layer.effect(PreviewAutomationBroker, make);
apps/server/src/mcp/toolkits/preview/handlers.ts+3
......@@ -3,6 +3,7 @@ import type {
33 PreviewAutomationOperation,
44 PreviewAutomationRecordingArtifact,
55 PreviewAutomationRecordingStatus,
6 PreviewAutomationResizeResult,
67 PreviewAutomationSnapshot,
78 PreviewAutomationStatus,
89} from "@t3tools/contracts";
......@@ -39,6 +40,8 @@ const handlers = {
3940 reuseExistingTab: input.reuseExistingTab ?? true,
4041 }),
4142 preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
43 preview_resize: (input) =>
44 invoke<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),
4245 preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}),
4346 preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)),
4447 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 {
77 PreviewAutomationPressInput,
88 PreviewAutomationRecordingArtifact,
99 PreviewAutomationRecordingStatus,
10 PreviewAutomationResizeInput,
11 PreviewAutomationResizeResult,
1012 PreviewAutomationScrollInput,
1113 PreviewAutomationSnapshot,
1214 PreviewAutomationStatus,
......@@ -35,7 +37,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>
3537
3638export const PreviewStatusTool = Tool.make("preview_status", {
3739 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.",
3941 success: PreviewAutomationStatus,
4042 failure: PreviewAutomationError,
4143 dependencies,
......@@ -69,6 +71,19 @@ export const PreviewNavigateTool = safeBrowserTool(
6971 }).annotate(Tool.Title, "Navigate browser preview"),
7072);
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
7287export const PreviewSnapshotTool = readonlyBrowserTool(
7388 Tool.make("preview_snapshot", {
7489 description:
......@@ -168,6 +183,7 @@ export const PreviewToolkit = Toolkit.make(
168183 PreviewStatusTool,
169184 PreviewOpenTool,
170185 PreviewNavigateTool,
186 PreviewResizeTool,
171187 PreviewSnapshotTool,
172188 PreviewClickTool,
173189 PreviewTypeTool,
......@@ -183,6 +199,7 @@ export const PreviewStandardToolkit = Toolkit.make(
183199 PreviewStatusTool,
184200 PreviewOpenTool,
185201 PreviewNavigateTool,
202 PreviewResizeTool,
186203 PreviewClickTool,
187204 PreviewTypeTool,
188205 PreviewPressTool,
apps/server/src/preview/Manager.test.ts+55
......@@ -151,6 +151,61 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {
151151 }),
152152 );
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
154209 it.effect("reportStatus emits failed for LoadFailed nav", () =>
155210 Effect.gen(function* () {
156211 const threadId = freshThreadId();
apps/server/src/preview/Manager.ts+38
......@@ -20,6 +20,8 @@ import {
2020 type PreviewOpenInput,
2121 type PreviewRefreshInput,
2222 type PreviewReportStatusInput,
23 type PreviewResizeInput,
24 FILL_PREVIEW_VIEWPORT,
2325 PreviewSessionLookupError,
2426 type PreviewSessionSnapshot,
2527} from "@t3tools/contracts";
......@@ -45,6 +47,9 @@ export class PreviewManager extends Context.Service<
4547 input: PreviewNavigateInput,
4648 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
4749 readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>;
50 readonly resize: (
51 input: PreviewResizeInput,
52 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
4853 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;
4954 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;
5055 readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>;
......@@ -114,6 +119,7 @@ const buildLoadingSnapshot = (input: {
114119 navStatus: { _tag: "Loading", url: input.url, title: input.title },
115120 canGoBack: false,
116121 canGoForward: false,
122 viewport: FILL_PREVIEW_VIEWPORT,
117123 updatedAt: input.updatedAt,
118124});
119125
......@@ -127,6 +133,7 @@ const buildIdleSnapshot = (input: {
127133 navStatus: { _tag: "Idle" },
128134 canGoBack: false,
129135 canGoForward: false,
136 viewport: FILL_PREVIEW_VIEWPORT,
130137 updatedAt: input.updatedAt,
131138});
132139
......@@ -237,6 +244,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
237244 navStatus: { _tag: "Success", url, title: resolvedTitle },
238245 canGoBack: session.snapshot.canGoBack,
239246 canGoForward: session.snapshot.canGoForward,
247 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
240248 updatedAt,
241249 };
242250 return {
......@@ -269,6 +277,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
269277 navStatus: input.navStatus,
270278 canGoBack: input.canGoBack,
271279 canGoForward: input.canGoForward,
280 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
272281 updatedAt,
273282 };
274283 const emit: PreviewEvent =
......@@ -299,6 +308,34 @@ export const make = Effect.gen(function* PreviewManagerMake() {
299308 );
300309 });
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
302339 const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")(
303340 function* (input) {
304341 // Verify the session exists; the desktop bridge handles the actual reload
......@@ -360,6 +397,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
360397 open,
361398 navigate,
362399 reportStatus,
400 resize,
363401 refresh,
364402 close,
365403 list,
apps/server/src/server.test.ts+41
......@@ -653,6 +653,7 @@ const buildAppUnderTest = (options?: {
653653 Layer.mock(PreviewManager.PreviewManager)({
654654 open: () => Effect.die("PreviewManager not stubbed in this test"),
655655 navigate: () => Effect.die("PreviewManager not stubbed in this test"),
656 resize: () => Effect.die("PreviewManager not stubbed in this test"),
656657 reportStatus: () => Effect.void,
657658 refresh: () => Effect.void,
658659 close: () => Effect.void,
......@@ -4125,6 +4126,46 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
41254126 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
41264127 );
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
41284169 it.effect("rejects websocket rpc handshake when session authentication is missing", () =>
41294170 Effect.gen(function* () {
41304171 const fs = yield* FileSystem.FileSystem;
apps/server/src/server.ts+2-1
......@@ -36,6 +36,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide
3636import * as TerminalManager from "./terminal/Manager.ts";
3737import * as McpHttpServer from "./mcp/McpHttpServer.ts";
3838import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
39import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
3940import * as PreviewManager from "./preview/Manager.ts";
4041import * as PortScanner from "./preview/PortScanner.ts";
4142import * as ProcessRunner from "./processRunner.ts";
......@@ -357,7 +358,7 @@ export const makeRoutesLayer = Layer.mergeAll(
357358 websocketRpcRouteLayer,
358359 ),
359360 McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),
360).pipe(Layer.provide(browserApiCorsLayer));
361).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer));
361362
362363export const makeServerLayer = Layer.unwrap(
363364 Effect.gen(function* () {
apps/server/src/ws.ts+19-19
......@@ -330,14 +330,14 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
330330 [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope],
331331 [WS_METHODS.previewOpen, AuthOrchestrationOperateScope],
332332 [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope],
333 [WS_METHODS.previewResize, AuthOrchestrationOperateScope],
333334 [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope],
334335 [WS_METHODS.previewClose, AuthOrchestrationOperateScope],
335336 [WS_METHODS.previewList, AuthOrchestrationReadScope],
336337 [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope],
337338 [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope],
338339 [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope],
339 [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope],
340 [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope],
340 [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope],
341341 [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope],
342342 [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope],
343343 [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope],
......@@ -385,7 +385,10 @@ function toAuthAccessStreamEvent(
385385 }
386386}
387387
388const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
388const makeWsRpcLayer = (
389 currentSession: EnvironmentAuth.AuthenticatedSession,
390 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],
391) =>
389392 WsRpcGroup.toLayer(
390393 Effect.gen(function* () {
391394 const currentSessionId = currentSession.sessionId;
......@@ -400,7 +403,6 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
400403 const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService;
401404 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
402405 const terminalManager = yield* TerminalManager.TerminalManager;
403 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
404406 const previewManager = yield* PreviewManager.PreviewManager;
405407 const portDiscovery = yield* PortScanner.PortDiscovery;
406408 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
......@@ -1622,6 +1624,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
16221624 observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), {
16231625 "rpc.aggregate": "preview",
16241626 }),
1627 [WS_METHODS.previewResize]: (input) =>
1628 observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), {
1629 "rpc.aggregate": "preview",
1630 }),
16251631 [WS_METHODS.previewRefresh]: (input) =>
16261632 observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), {
16271633 "rpc.aggregate": "preview",
......@@ -1650,16 +1656,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
16501656 previewAutomationBroker.respond(input),
16511657 { "rpc.aggregate": "preview-automation" },
16521658 ),
1653 [WS_METHODS.previewAutomationReportOwner]: (input) =>
1654 observeRpcEffect(
1655 WS_METHODS.previewAutomationReportOwner,
1656 previewAutomationBroker.reportOwner(input),
1657 { "rpc.aggregate": "preview-automation" },
1658 ),
1659 [WS_METHODS.previewAutomationClearOwner]: (input) =>
1659 [WS_METHODS.previewAutomationFocusHost]: (input) =>
16601660 observeRpcEffect(
1661 WS_METHODS.previewAutomationClearOwner,
1662 previewAutomationBroker.clearOwner(input),
1661 WS_METHODS.previewAutomationFocusHost,
1662 previewAutomationBroker.focusHost(input),
16631663 { "rpc.aggregate": "preview-automation" },
16641664 ),
16651665 [WS_METHODS.subscribePreviewEvents]: (_input) =>
......@@ -1791,8 +1791,9 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
17911791 );
17921792
17931793export const websocketRpcRouteLayer = Layer.unwrap(
1794 Effect.succeed(
1795 HttpRouter.add(
1794 Effect.gen(function* () {
1795 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
1796 return HttpRouter.add(
17961797 "GET",
17971798 "/ws",
17981799 Effect.gen(function* () {
......@@ -1811,9 +1812,8 @@ export const websocketRpcRouteLayer = Layer.unwrap(
18111812 disableTracing: true,
18121813 }).pipe(
18131814 Effect.provide(
1814 makeWsRpcLayer(session).pipe(
1815 makeWsRpcLayer(session, previewAutomationBroker).pipe(
18151816 Layer.provideMerge(RpcSerialization.layerJson),
1816 Layer.provide(PreviewAutomationBroker.layer),
18171817 Layer.provide(ProviderMaintenanceRunner.layer),
18181818 Layer.provide(
18191819 SourceControlDiscovery.layer.pipe(
......@@ -1850,6 +1850,6 @@ export const websocketRpcRouteLayer = Layer.unwrap(
18501850 EnvironmentInternalError: HttpServerRespondable.toResponse,
18511851 }),
18521852 ),
1853 ),
1854 ),
1853 );
1854 }),
18551855);
apps/web/src/AppRoot.test.tsx+5-3
......@@ -3,20 +3,22 @@ import { RouterProvider } from "@tanstack/react-router";
33import { describe, expect, it } from "vite-plus/test";
44
55import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
6import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
67import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
78import type { AppRouter } from "./router";
89import { AppRoot } from "./AppRoot";
910
1011describe("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", () => {
1213 const root = AppRoot({ router: {} as AppRouter });
1314
1415 expect(root.type).toBe(AppAtomRegistryProvider);
1516 const children = Children.toArray(
1617 (root as ReactElement<{ readonly children: ReactNode }>).props.children,
1718 );
18 expect(children).toHaveLength(2);
19 expect(children).toHaveLength(3);
1920 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);
2123 });
2224});
apps/web/src/AppRoot.tsx+2
......@@ -1,6 +1,7 @@
11import { RouterProvider } from "@tanstack/react-router";
22
33import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
4import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
45import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
56import type { AppRouter } from "./router";
67
......@@ -13,6 +14,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
1314 return (
1415 <AppAtomRegistryProvider>
1516 <RouterProvider router={router} />
17 <PreviewAutomationHosts />
1618 <ElectronBrowserHost />
1719 </AppAtomRegistryProvider>
1820 );
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 @@
11"use client";
22
33import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
4import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
45import { useEffect, useMemo } from "react";
56
67import { isElectron } from "~/env";
......@@ -22,7 +23,7 @@ export function ElectronBrowserHost() {
2223 ? Object.values(previewState.sessions).map((snapshot) => ({
2324 threadRef,
2425 snapshot,
25 active: previewState.activeTabId === snapshot.tabId,
26 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
2627 }))
2728 : [];
2829 }),
......@@ -73,7 +74,7 @@ export function ElectronBrowserHost() {
7374 if (!isElectron) return null;
7475 return (
7576 <div className="contents" data-electron-browser-host>
76 {sessions.map(({ threadRef, snapshot }) => {
77 {sessions.map(({ threadRef, snapshot, zoomFactor }) => {
7778 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
7879 return (
7980 <HostedBrowserWebview
......@@ -81,6 +82,8 @@ export function ElectronBrowserHost() {
8182 threadRef={threadRef}
8283 tabId={snapshot.tabId}
8384 initialUrl={url}
85 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
86 zoomFactor={zoomFactor}
8487 />
8588 );
8689 })}
apps/web/src/browser/HostedBrowserWebview.tsx+163-22
......@@ -1,16 +1,22 @@
11"use client";
22
3import type { ScopedThreadRef } from "@t3tools/contracts";
3import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts";
44import { useShallow } from "zustand/react/shallow";
5import { useCallback, useEffect, useRef } from "react";
5import { useCallback, useEffect, useRef, useState } from "react";
66
77import { previewBridge } from "~/components/preview/previewBridge";
88import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
9import { cn } from "~/lib/utils";
910
1011import { 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";
1217import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
1318import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
19import { useBrowserViewportResize } from "./useBrowserViewportResize";
1420
1521interface ElectronWebview extends HTMLElement {
1622 src: string;
......@@ -18,6 +24,7 @@ interface ElectronWebview extends HTMLElement {
1824 preload?: string;
1925 webpreferences?: string;
2026 getWebContentsId: () => number;
27 executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>;
2128}
2229
2330declare global {
......@@ -30,13 +37,25 @@ export function HostedBrowserWebview(props: {
3037 readonly threadRef: ScopedThreadRef;
3138 readonly tabId: string;
3239 readonly initialUrl: string | null;
40 readonly viewport: PreviewViewportSetting;
41 readonly zoomFactor: number;
3342}) {
34 const { threadRef, tabId, initialUrl } = props;
43 const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props;
3544 const config = usePreviewWebviewConfig(threadRef.environmentId);
36 const initialSrcRef = useRef(initialUrl ?? "about:blank");
45 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
3746 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
47 const wrapperRef = useRef<HTMLDivElement | null>(null);
3848 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 );
4059 const recording = useActiveBrowserRecordingTabId() === tabId;
4160
4261 usePreviewBridge({ threadRef, tabId });
......@@ -89,10 +108,69 @@ export function HostedBrowserWebview(props: {
89108 };
90109 }, [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
92171 if (!config) return null;
93 const active = presentation?.visible === true && presentation.rect !== null;
94 const lastRect = presentation?.rect;
95 const style =
172
173 const wrapperStyle =
96174 active && lastRect
97175 ? {
98176 left: lastRect.x,
......@@ -105,23 +183,86 @@ export function HostedBrowserWebview(props: {
105183 : {
106184 left: 0,
107185 top: 0,
108 width: lastRect?.width ?? 1280,
109 height: lastRect?.height ?? 800,
186 width: hiddenSize.width,
187 height: hiddenSize.height,
110188 zIndex: recording ? 0 : -1,
111189 pointerEvents: "none" as const,
112190 };
113191
114192 return (
115 <webview
116 ref={setWebviewRef}
117 src={initialSrcRef.current}
118 partition={config.partition}
119 webpreferences={config.webPreferences}
120 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}
121 data-preview-tab={tabId}
122 aria-hidden={active ? undefined : true}
123 className="fixed flex overflow-hidden bg-background"
124 style={style}
125 />
193 <div
194 ref={wrapperRef}
195 className="fixed overflow-hidden bg-muted/35"
196 style={{ ...wrapperStyle, overscrollBehavior: "contain" }}
197 onScroll={syncContentPresentation}
198 data-preview-viewport={tabId}
199 >
200 <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>
201 {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? (
202 <BrowserDeviceToolbar
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>
126267 );
127268}
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 {
9090let active: ActiveRecording | null = null;
9191let unsubscribeFrames: (() => void) | null = null;
9292
93export function readActiveBrowserRecordingTabId(): string | null {
94 return active?.tabId ?? null;
95}
96
9397const preferredMimeType = (): string => {
9498 const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"];
9599 return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm";
......@@ -137,10 +141,11 @@ export async function startBrowserRecording(tabId: string): Promise<string> {
137141 activeTabId: active.tabId,
138142 });
139143 }
140 const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect;
144 const surface = useBrowserSurfaceStore.getState().byTabId[tabId];
145 const recordingSize = surface?.content ?? surface?.rect;
141146 const canvas = document.createElement("canvas");
142 canvas.width = Math.max(1, rect?.width ?? 1280);
143 canvas.height = Math.max(1, rect?.height ?? 800);
147 canvas.width = Math.max(1, recordingSize?.width ?? 1280);
148 canvas.height = Math.max(1, recordingSize?.height ?? 800);
144149 const context = canvas.getContext("2d", { alpha: false });
145150 if (!context) {
146151 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 {
1010export interface BrowserSurfacePresentation {
1111 readonly rect: BrowserSurfaceRect | null;
1212 readonly visible: boolean;
13 readonly content: BrowserSurfaceContentPresentation | null;
1314 readonly updatedAt: number;
1415}
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
1627interface BrowserSurfaceStoreState {
1728 readonly byTabId: Record<string, BrowserSurfacePresentation>;
1829 readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void;
30 readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void;
1931 readonly hide: (tabId: string) => void;
2032}
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
2254const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean =>
2355 left !== null &&
2456 left.x === right.x &&
......@@ -35,7 +67,43 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
3567 return {
3668 byTabId: {
3769 ...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() },
39107 },
40108 };
41109 }),
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";
129129import { closePreviewSession } from "./preview/closePreviewSession";
130130import { subscribePreviewAction } from "./preview/previewActionBus";
131131import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
132import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
133132import { RightPanelTabs } from "./RightPanelTabs";
134133import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
135134import { BranchToolbar } from "./BranchToolbar";
......@@ -4704,9 +4703,6 @@ function ChatViewContent(props: ChatViewProps) {
47044703
47054704 return (
47064705 <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}
47104706 {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
47114707 <div
47124708 className={cn(
apps/web/src/components/preview/AgentBrowserCursor.tsx+12-2
......@@ -5,6 +5,7 @@ import { MousePointer2 } from "lucide-react";
55import { useEffect, useState } from "react";
66
77import { useBrowserPointerStore } from "~/browser/browserPointerStore";
8import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
89
910import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic";
1011
......@@ -17,6 +18,7 @@ export function AgentBrowserCursor(props: {
1718}) {
1819 const { tabId, zoomFactor, controller } = props;
1920 const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null);
21 const content = useBrowserSurfaceStore((state) => state.byTabId[tabId]?.content ?? null);
2022
2123 if (!event) return null;
2224
......@@ -24,6 +26,7 @@ export function AgentBrowserCursor(props: {
2426 <AgentBrowserCursorEvent
2527 key={event.sequence}
2628 event={event}
29 content={content}
2730 zoomFactor={zoomFactor}
2831 controller={controller}
2932 />
......@@ -32,10 +35,17 @@ export function AgentBrowserCursor(props: {
3235
3336function AgentBrowserCursorEvent(props: {
3437 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;
3545 readonly zoomFactor: number;
3646 readonly controller: BrowserController;
3747}) {
38 const { event, zoomFactor, controller } = props;
48 const { event, content, zoomFactor, controller } = props;
3949 const [active, setActive] = useState(true);
4050
4151 useEffect(() => {
......@@ -48,7 +58,7 @@ function AgentBrowserCursorEvent(props: {
4858 className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none"
4959 style={{
5060 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)`,
5262 }}
5363 aria-hidden="true"
5464 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 {
1919 hasWebContents: boolean;
2020 /** Current zoom factor as a number (1.0 = 100%). */
2121 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;
2226}
2327
2428/**
......@@ -26,7 +30,13 @@ interface Props {
2630 * controls, and storage-clearing actions. Only mounted by `PreviewView`
2731 * when the desktop bridge is present, so we can call it unconditionally.
2832 */
29export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) {
33export function PreviewMoreMenu({
34 tabId,
35 hasWebContents,
36 zoomFactor,
37 deviceToolbarVisible,
38 onToggleDeviceToolbar,
39}: Props) {
3040 if (!previewBridge) return null;
3141 const bridge = previewBridge;
3242 const tabDisabled = !tabId || !hasWebContents;
......@@ -59,6 +69,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) {
5969 <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}>
6070 Open DevTools
6171 </MenuItem>
72 <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}>
73 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
74 </MenuItem>
75 <MenuSeparator />
6276 {/*
6377 Zoom row: label + inline control cluster. `closeOnClick=false`
6478 keeps the menu open while the user clicks the +/− buttons.
apps/web/src/components/preview/PreviewView.tsx+69-2
......@@ -1,13 +1,22 @@
11"use client";
22
33import { 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";
510import { useCallback, useEffect, useRef, useState } from "react";
611
712import { useComposerDraftStore } from "~/composerDraftStore";
813import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";
914import { ensureLocalApi } from "~/localApi";
10import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore";
15import {
16 rememberPreviewUrl,
17 updatePreviewServerSnapshot,
18 useThreadPreviewState,
19} from "~/previewStateStore";
1120import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
1221import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments";
1322import { previewEnvironment } from "~/state/preview";
......@@ -20,10 +29,16 @@ import { PreviewChromeRow } from "./PreviewChromeRow";
2029import { formatPreviewUrl } from "./previewUrlPresentation";
2130import { PreviewEmptyState } from "./PreviewEmptyState";
2231import { PreviewMoreMenu } from "./PreviewMoreMenu";
32import {
33 commitBrowserViewportChange,
34 subscribeBrowserViewportChange,
35} from "~/browser/browserViewportActions";
36import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";
2337import { PreviewUnreachable } from "./PreviewUnreachable";
2438import { revealInFileExplorerLabel } from "./fileExplorerLabel";
2539import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
2640import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
41import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
2742import { useLoadingProgress } from "./useLoadingProgress";
2843import { usePreviewSession } from "./usePreviewSession";
2944import { ZoomIndicator } from "./ZoomIndicator";
......@@ -60,6 +75,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
6075 const environment = useEnvironment(threadRef.environmentId);
6176 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);
6277 const open = useAtomCommand(previewEnvironment.open);
78 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
6379
6480 usePreviewSession(threadRef);
6581
......@@ -91,6 +107,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
91107 environmentHttpBaseUrl,
92108 }) ?? undefined)
93109 : undefined;
110 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
111 const panelRect = useBrowserSurfaceStore((state) =>
112 tabId ? (state.byTabId[tabId]?.rect ?? null) : null,
113 );
94114
95115 const handleSubmitUrl = useCallback(
96116 async (next: string) => {
......@@ -131,6 +151,51 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
131151 if (previewBridge && tabId) void previewBridge.resetZoom(tabId);
132152 }, [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
134199 const handleBack = useCallback(() => {
135200 if (previewBridge && tabId) void previewBridge.goBack(tabId);
136201 }, [tabId]);
......@@ -527,6 +592,8 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
527592 tabId={tabId}
528593 hasWebContents={desktopOverlay !== null}
529594 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
595 deviceToolbarVisible={viewport._tag !== "fill"}
596 onToggleDeviceToolbar={handleToggleDeviceToolbar}
530597 />
531598 ) : null
532599 }
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 @@
11import {
22 EnvironmentId,
3 type PreviewAutomationOwner,
3 type PreviewAutomationHost,
44 PreviewAutomationOperation,
55 type PreviewAutomationRequest,
66 type PreviewAutomationResponse,
......@@ -13,7 +13,7 @@ import * as Schema from "effect/Schema";
1313export interface PreviewAutomationOperationContext {
1414 readonly requestId: PreviewAutomationRequest["requestId"];
1515 readonly operation: PreviewAutomationRequest["operation"];
16 readonly environmentId: PreviewAutomationOwner["environmentId"];
16 readonly environmentId: PreviewAutomationHost["environmentId"];
1717 readonly threadId: PreviewAutomationRequest["threadId"];
1818 readonly tabId: Exclude<PreviewAutomationRequest["tabId"], undefined> | null;
1919}
......@@ -56,21 +56,22 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC
5656 }
5757}
5858
59export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass<PreviewAutomationStaleOwnerError>()(
60 "PreviewAutomationStaleOwnerError",
59export class PreviewAutomationViewportTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationViewportTimeoutError>()(
60 "PreviewAutomationViewportTimeoutError",
6161 {
6262 requestId: TrimmedNonEmptyString,
6363 environmentId: EnvironmentId,
64 expectedThreadId: ThreadId,
65 requestedThreadId: ThreadId,
64 threadId: ThreadId,
65 tabId: PreviewTabId,
66 timeoutMs: Schema.Int,
6667 },
6768) {
6869 get responseTag() {
69 return "PreviewAutomationUnavailableError" as const;
70 return "PreviewAutomationTimeoutError" as const;
7071 }
7172
7273 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.`;
7475 }
7576}
7677
......@@ -100,7 +101,7 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError
100101 requestId: TrimmedNonEmptyString,
101102 environmentId: EnvironmentId,
102103 threadId: ThreadId,
103 tabId: PreviewTabId,
104 tabId: Schema.NullOr(PreviewTabId),
104105 },
105106) {
106107 get responseTag() {
......@@ -108,10 +109,65 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError
108109 }
109110
110111 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}.`;
112113 }
113114}
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
115171export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<PreviewAutomationOperationError>()(
116172 "PreviewAutomationOperationError",
117173 {
......@@ -125,9 +181,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre
125181) {
126182 static fromCause(
127183 input: PreviewAutomationOperationContext & { readonly cause: unknown },
128 ): PreviewAutomationOwnerError {
129 return isPreviewAutomationOwnerError(input.cause)
130 ? input.cause
184 ): PreviewAutomationHostError {
185 if (isPreviewAutomationHostError(input.cause)) return input.cause;
186 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 })
131196 : new PreviewAutomationOperationError(input);
132197 }
133198
......@@ -140,20 +205,21 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre
140205 }
141206}
142207
143export const PreviewAutomationOwnerError = Schema.Union([
208export const PreviewAutomationHostError = Schema.Union([
144209 PreviewAutomationOverlayTimeoutError,
145210 PreviewAutomationNavigationTimeoutError,
146 PreviewAutomationStaleOwnerError,
211 PreviewAutomationViewportTimeoutError,
147212 PreviewAutomationTargetUnavailableError,
148213 PreviewAutomationRecordingNotActiveError,
214 PreviewAutomationTargetNotEditableHostError,
149215 PreviewAutomationOperationError,
150216]);
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(
156 error: PreviewAutomationOwnerError,
221export function serializePreviewAutomationHostError(
222 error: PreviewAutomationHostError,
157223): NonNullable<PreviewAutomationResponse["error"]> {
158224 const detail = Object.fromEntries(
159225 Object.entries(error).filter(
apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts+226-15
......@@ -2,13 +2,18 @@ import {
22 EnvironmentId,
33 type PreviewAutomationRequest,
44 type PreviewAutomationResponse,
5 type PreviewAutomationStreamEvent,
56 PreviewTabId,
67 ThreadId,
78} from "@t3tools/contracts";
89import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
910import { describe, expect, it, vi } from "vite-plus/test";
1011
11import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors";
12import {
13 PreviewAutomationRecordingNotActiveError,
14 PreviewAutomationTargetUnavailableError,
15 PreviewAutomationViewportTimeoutError,
16} from "./previewAutomationErrors";
1217import {
1318 createPreviewAutomationRequestConsumerAtom,
1419 serializePreviewAutomationError,
......@@ -17,6 +22,8 @@ import {
1722const environmentId = EnvironmentId.make("environment-1");
1823const threadId = ThreadId.make("thread-1");
1924const tabId = PreviewTabId.make("tab-1");
25const clientId = "client-1";
26const connectionId = "connection-1";
2027
2128const request = (
2229 requestId: string,
......@@ -30,10 +37,88 @@ const request = (
3037 ...overrides,
3138});
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
3355describe("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
34119 it("consumes every request emitted before React can render", async () => {
35 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationRequest, Error>>(
36 AsyncResult.initial<PreviewAutomationRequest, Error>(false),
120 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
121 AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false),
37122 );
38123 const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({
39124 requestId: value.requestId,
......@@ -42,18 +127,21 @@ describe("previewAutomationRequestConsumer", () => {
42127 const respond = vi.fn(async (response: PreviewAutomationResponse) => {
43128 responses.push(response);
44129 });
130 const state = consumerState(handleRequest);
45131 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
46132 requestsAtom,
133 clientId,
134 connectionAtom: state.connectionAtom,
47135 environmentId,
48 handleRequest,
136 requestHandlerAtom: state.requestHandlerAtom,
49137 respond,
50138 label: "test:preview-automation-consumer",
51139 });
52140 const registry = AtomRegistry.make();
53141 registry.mount(consumerAtom);
54142
55 registry.set(requestsAtom, AsyncResult.success(request("request-1")));
56 registry.set(requestsAtom, AsyncResult.success(request("request-2")));
143 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-1")));
144 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-2")));
57145
58146 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
59147 expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([
......@@ -64,15 +152,50 @@ describe("previewAutomationRequestConsumer", () => {
64152 registry.dispose();
65153 });
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
67187 it("consumes a request that arrived immediately before the consumer mounted", async () => {
68188 const requestsAtom = Atom.make(
69 AsyncResult.success<PreviewAutomationRequest, Error>(request("request-ready")),
189 AsyncResult.success<PreviewAutomationStreamEvent, Error>(requestEvent("request-ready")),
70190 );
71191 const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
192 const state = consumerState(async () => undefined);
72193 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
73194 requestsAtom,
195 clientId,
196 connectionAtom: state.connectionAtom,
74197 environmentId,
75 handleRequest: async () => undefined,
198 requestHandlerAtom: state.requestHandlerAtom,
76199 respond,
77200 label: "test:preview-automation-initial-request",
78201 });
......@@ -81,7 +204,12 @@ describe("previewAutomationRequestConsumer", () => {
81204 registry.mount(consumerAtom);
82205
83206 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 });
85213 registry.dispose();
86214 });
87215
......@@ -118,6 +246,84 @@ describe("previewAutomationRequestConsumer", () => {
118246 });
119247 });
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
121327 it("correlates unexpected failures without exposing cause details", () => {
122328 const cause = new Error("private bridge token: preview-secret");
123329 const context = {
......@@ -145,16 +351,19 @@ describe("previewAutomationRequestConsumer", () => {
145351 });
146352
147353 it("sanitizes unexpected handler failures at the response boundary", async () => {
148 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationRequest, Error>>(
149 AsyncResult.initial<PreviewAutomationRequest, Error>(false),
354 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
355 AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false),
150356 );
151357 const responses: PreviewAutomationResponse[] = [];
358 const state = consumerState(async () => {
359 throw new Error("desktop IPC secret: do-not-return");
360 });
152361 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
153362 requestsAtom,
363 clientId,
364 connectionAtom: state.connectionAtom,
154365 environmentId,
155 handleRequest: async () => {
156 throw new Error("desktop IPC secret: do-not-return");
157 },
366 requestHandlerAtom: state.requestHandlerAtom,
158367 respond: async (response) => {
159368 responses.push(response);
160369 },
......@@ -166,7 +375,7 @@ describe("previewAutomationRequestConsumer", () => {
166375 registry.set(
167376 requestsAtom,
168377 AsyncResult.success(
169 request("request-failed", {
378 requestEvent("request-failed", {
170379 operation: "click",
171380 tabId,
172381 }),
......@@ -175,6 +384,8 @@ describe("previewAutomationRequestConsumer", () => {
175384
176385 await vi.waitFor(() => expect(responses).toHaveLength(1));
177386 expect(responses[0]).toEqual({
387 clientId,
388 connectionId,
178389 requestId: "request-failed",
179390 ok: false,
180391 error: {
apps/web/src/components/preview/previewAutomationRequestConsumer.ts+76-42
......@@ -1,87 +1,121 @@
11import type {
2 PreviewAutomationOwner,
2 PreviewAutomationHost,
33 PreviewAutomationRequest,
44 PreviewAutomationResponse,
5 PreviewAutomationStreamEvent,
56} from "@t3tools/contracts";
67import { AsyncResult, Atom } from "effect/unstable/reactivity";
78
89import {
910 PreviewAutomationOperationError,
1011 type PreviewAutomationOperationContext,
11 serializePreviewAutomationOwnerError,
12 serializePreviewAutomationHostError,
1213} from "./previewAutomationErrors";
1314
14type AutomationRequestResult<E> = AsyncResult.AsyncResult<PreviewAutomationRequest, 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}
15type AutomationStreamResult<E> = AsyncResult.AsyncResult<PreviewAutomationStreamEvent, E>;
2916
3017export function serializePreviewAutomationError(
3118 error: unknown,
3219 context: PreviewAutomationOperationContext,
3320): NonNullable<PreviewAutomationResponse["error"]> {
34 return serializePreviewAutomationOwnerError(
21 return serializePreviewAutomationHostError(
3522 PreviewAutomationOperationError.fromCause({ ...context, cause: error }),
3623 );
3724}
3825
3926export function createPreviewAutomationRequestConsumerAtom<E>(options: {
40 readonly requestsAtom: Atom.Atom<AutomationRequestResult<E>>;
41 readonly environmentId: PreviewAutomationOwner["environmentId"];
42 readonly handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>;
27 readonly requestsAtom: Atom.Atom<AutomationStreamResult<E>>;
28 readonly clientId: PreviewAutomationHost["clientId"];
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 }>;
4334 readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>;
4435 readonly label: string;
4536}): Atom.Atom<void> {
4637 return Atom.make((get) => {
38 get.mount(options.connectionAtom);
39 get.mount(options.requestHandlerAtom);
4740 let disposed = false;
41 let activeConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
42 let connectionExplicitlyAnnounced = false;
43 let reportedConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
4844 let requestsVersion = 0;
4945
50 const consume = (result: AutomationRequestResult<E>) => {
46 const consume = (result: AutomationStreamResult<E>) => {
5147 if (!AsyncResult.isSuccess(result)) return;
52 const request = result.value;
53 void options.handleRequest(request).then(
54 (value) =>
55 options.respond({
56 requestId: request.requestId,
57 ok: true,
58 ...(value === undefined ? {} : { result: value }),
59 }),
60 (error) =>
61 options.respond({
62 requestId: request.requestId,
63 ok: false,
64 error: serializePreviewAutomationError(error, {
48 const event = result.value;
49 if (event.type === "connected") {
50 activeConnectionId = event.connectionId;
51 connectionExplicitlyAnnounced = true;
52 } else if (activeConnectionId === null) {
53 activeConnectionId = event.connectionId;
54 } else if (activeConnectionId !== event.connectionId) {
55 if (connectionExplicitlyAnnounced) return;
56 activeConnectionId = event.connectionId;
57 }
58 if (reportedConnectionId !== event.connectionId) {
59 reportedConnectionId = event.connectionId;
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,
6582 requestId: request.requestId,
66 operation: request.operation,
67 environmentId: options.environmentId,
68 threadId: request.threadId,
69 tabId: request.tabId ?? null,
83 ok: false,
84 error: serializePreviewAutomationError(error, {
85 requestId: request.requestId,
86 operation: request.operation,
87 environmentId: options.environmentId,
88 threadId: request.threadId,
89 tabId: request.tabId ?? null,
90 }),
7091 }),
71 }),
72 );
92 );
7393 };
7494
7595 get.addFinalizer(() => {
7696 disposed = true;
7797 });
7898 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 }
79107 get.subscribe(options.requestsAtom, (result) => {
80108 requestsVersion += 1;
81109 consume(result);
82110 });
83111 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 }
85119 });
86120 }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label));
87121}
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 {
1111 applyPreviewServerEvent,
1212 applyPreviewServerSnapshot,
1313 readThreadPreviewState,
14 reconcilePreviewServerSessions,
1415} from "~/previewStateStore";
1516import { previewEnvironment } from "~/state/preview";
1617
......@@ -50,9 +51,7 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
5051 if (result.value.sessions.length > 0) {
5152 recoveringUrl = null;
5253 recoveryId += 1;
53 for (const snapshot of result.value.sessions) {
54 applyPreviewServerSnapshot(threadRef, snapshot);
55 }
54 reconcilePreviewServerSessions(threadRef, result.value.sessions);
5655 return;
5756 }
5857
apps/web/src/previewStateStore.test.ts+88
......@@ -11,10 +11,12 @@ import {
1111 cancelPreviewSessionClose,
1212 previewStateAtom,
1313 readThreadPreviewState,
14 reconcilePreviewServerSessions,
1415 rememberPreviewUrl,
1516 removePreviewThread,
1617 resetPreviewStateForTests,
1718 setActivePreviewTab,
19 updatePreviewServerSnapshot,
1820} from "./previewStateStore";
1921
2022const environmentId = "env-1" as EnvironmentId;
......@@ -108,6 +110,34 @@ describe("previewStateStore (single-tab)", () => {
108110 }
109111 });
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
111141 it("failed event flips the snapshot to LoadFailed when tabId matches", () => {
112142 const snapshot = makeSnapshot();
113143 applyPreviewServerEvent(ref, {
......@@ -292,6 +322,64 @@ describe("previewStateStore (single-tab)", () => {
292322 expect(state.desktopOverlay?.canGoBack).toBe(true);
293323 });
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
295383 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
296384 const snapshot = makeSnapshot();
297385 applyPreviewServerSnapshot(ref, snapshot);
apps/web/src/previewStateStore.ts+89-9
......@@ -112,14 +112,26 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => {
112112 return next.slice(0, PREVIEW_RECENT_URL_LIMIT);
113113};
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
115130const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => {
116131 if (!current.sessions[tabId]) return current;
117132 const { [tabId]: _closed, ...sessions } = current.sessions;
118133 const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;
119 const nextSnapshot =
120 Object.values(sessions)
121 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
122 .at(-1) ?? null;
134 const nextSnapshot = latestSnapshot(sessions);
123135 const activeTabId =
124136 current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId;
125137 const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;
......@@ -163,7 +175,8 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven
163175 updateThreadPreviewState(ref, (current) => {
164176 switch (event.type) {
165177 case "opened":
166 case "navigated": {
178 case "navigated":
179 case "resized": {
167180 const snapshot = event.snapshot;
168181 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
169182 const recentlySeenUrls =
......@@ -228,10 +241,7 @@ export function applyPreviewServerSnapshot(
228241 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
229242 const existing = current.sessions[snapshot.tabId];
230243 if (existing && existing.updatedAt > snapshot.updatedAt) return current;
231 const recentlySeenUrls =
232 snapshot.navStatus._tag !== "Idle"
233 ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
234 : current.recentlySeenUrls;
244 const recentlySeenUrls = rememberSnapshotUrl(current.recentlySeenUrls, snapshot);
235245 return {
236246 ...current,
237247 snapshot,
......@@ -243,6 +253,76 @@ export function applyPreviewServerSnapshot(
243253 });
244254}
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
246326export function applyPreviewDesktopState(
247327 ref: ScopedThreadRef,
248328 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 {
99 createEnvironmentRpcSubscriptionAtomFamily,
1010} 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
1220export function createPreviewEnvironmentAtoms<R, E>(
1321 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
1422) {
......@@ -54,6 +62,12 @@ export function createPreviewEnvironmentAtoms<R, E>(
5462 scheduler: lifecycleScheduler,
5563 concurrency: lifecycleConcurrency,
5664 }),
65 resize: createEnvironmentRpcCommand(runtime, {
66 label: "environment-data:preview:resize",
67 tag: WS_METHODS.previewResize,
68 scheduler: lifecycleScheduler,
69 concurrency: lifecycleConcurrency,
70 }),
5771 refresh: createEnvironmentRpcCommand(runtime, {
5872 label: "environment-data:preview:refresh",
5973 tag: WS_METHODS.previewRefresh,
......@@ -82,25 +96,17 @@ export function createPreviewEnvironmentAtoms<R, E>(
8296 scheduler: automationScheduler,
8397 concurrency: {
8498 mode: "singleFlight",
85 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.requestId]),
86 },
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]),
99 key: ({ environmentId, input }) =>
100 JSON.stringify([environmentId, input.connectionId, input.requestId]),
95101 },
96102 }),
97 clearAutomationOwner: createEnvironmentRpcCommand(runtime, {
98 label: "environment-data:preview:automation-clear-owner",
99 tag: WS_METHODS.previewAutomationClearOwner,
103 focusAutomationHost: createEnvironmentRpcCommand(runtime, {
104 label: "environment-data:preview:automation-focus-host",
105 tag: WS_METHODS.previewAutomationFocusHost,
100106 scheduler: automationScheduler,
101107 concurrency: {
102 mode: "serial",
103 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]),
108 mode: "latest",
109 key: previewAutomationHostFocusConcurrencyKey,
104110 },
105111 }),
106112 };
packages/contracts/src/ipc.ts+8-7
......@@ -69,19 +69,20 @@ import type {
6969 PreviewOpenInput,
7070 PreviewRefreshInput,
7171 PreviewReportStatusInput,
72 PreviewResizeInput,
7273 PreviewSessionSnapshot,
7374} from "./preview.ts";
7475import {
7576 PreviewAutomationClickInput,
7677 PreviewAutomationEvaluateInput,
77 PreviewAutomationOwner,
78 PreviewAutomationOwnerIdentity,
78 PreviewAutomationHost,
79 PreviewAutomationHostFocus,
7980 PreviewAutomationPressInput,
80 PreviewAutomationRequest,
8181 PreviewAutomationResponse,
8282 PreviewAutomationScrollInput,
8383 PreviewAutomationSnapshot,
8484 PreviewAutomationStatus,
85 PreviewAutomationStreamEvent,
8586 PreviewAutomationTypeInput,
8687 PreviewAutomationWaitForInput,
8788} from "./previewAutomation.ts";
......@@ -1154,19 +1155,19 @@ export interface EnvironmentApi {
11541155 preview: {
11551156 open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>;
11561157 navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>;
1158 resize: (input: typeof PreviewResizeInput.Encoded) => Promise<PreviewSessionSnapshot>;
11571159 refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>;
11581160 close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>;
11591161 list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>;
11601162 reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>;
11611163 automation: {
11621164 connect: (
1163 input: { clientId: string },
1164 callback: (request: PreviewAutomationRequest) => void,
1165 input: PreviewAutomationHost,
1166 callback: (event: PreviewAutomationStreamEvent) => void,
11651167 options?: { onResubscribe?: () => void },
11661168 ) => () => void;
11671169 respond: (response: PreviewAutomationResponse) => Promise<void>;
1168 reportOwner: (owner: PreviewAutomationOwner) => Promise<void>;
1169 clearOwner: (input: PreviewAutomationOwnerIdentity) => Promise<void>;
1170 focusHost: (input: PreviewAutomationHostFocus) => Promise<void>;
11701171 };
11711172 onEvent: (
11721173 callback: (event: PreviewEvent) => void,
packages/contracts/src/preview.test.ts+144
......@@ -6,12 +6,26 @@ import {
66 PreviewEvent,
77 PreviewNavStatus,
88 PreviewSessionSnapshot,
9 PreviewViewportSetting,
910} from "./preview.ts";
11import {
12 PreviewAutomationHost,
13 PreviewAutomationError,
14 PreviewAutomationResizeInput,
15 PreviewAutomationResizeResult,
16 PreviewAutomationStatus,
17} from "./previewAutomation.ts";
1018
1119const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);
1220const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);
1321const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);
1422const 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
1630describe("PreviewNavStatus", () => {
1731 it("decodes Idle", () => {
......@@ -68,6 +82,117 @@ describe("PreviewSessionSnapshot", () => {
6882 });
6983});
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
71196describe("PreviewEvent", () => {
72197 it("decodes opened", () => {
73198 const event = decodePreviewEvent({
......@@ -104,6 +229,25 @@ describe("PreviewEvent", () => {
104229 }
105230 });
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
107251 it("decodes closed without snapshot", () => {
108252 const event = decodePreviewEvent({
109253 type: "closed",
packages/contracts/src/preview.ts+110
......@@ -17,6 +17,100 @@ const Title = Schema.String.check(Schema.isMaxLength(512));
1717export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
1818export 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
20114export const PreviewNavStatus = Schema.Union([
21115 Schema.TaggedStruct("Idle", {}),
22116 Schema.TaggedStruct("Loading", {
......@@ -42,6 +136,8 @@ export const PreviewSessionSnapshot = Schema.Struct({
42136 navStatus: PreviewNavStatus,
43137 canGoBack: Schema.Boolean,
44138 canGoForward: Schema.Boolean,
139 /** Missing snapshots from older servers are treated as fill-panel mode. */
140 viewport: Schema.optional(PreviewViewportSetting),
45141 updatedAt: Schema.String,
46142});
47143export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
......@@ -76,6 +172,13 @@ export const PreviewRefreshInput = Schema.Struct({
76172});
77173export 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
79182export const PreviewCloseInput = Schema.Struct({
80183 threadId: ThreadId,
81184 tabId: Schema.optional(PreviewTabId),
......@@ -110,6 +213,12 @@ const PreviewNavigatedEvent = Schema.Struct({
110213 snapshot: PreviewSessionSnapshot,
111214});
112215
216const PreviewResizedEvent = Schema.Struct({
217 ...PreviewEventBaseSchema.fields,
218 type: Schema.Literal("resized"),
219 snapshot: PreviewSessionSnapshot,
220});
221
113222const PreviewFailedEvent = Schema.Struct({
114223 ...PreviewEventBaseSchema.fields,
115224 type: Schema.Literal("failed"),
......@@ -127,6 +236,7 @@ const PreviewClosedEvent = Schema.Struct({
127236export const PreviewEvent = Schema.Union([
128237 PreviewOpenedEvent,
129238 PreviewNavigatedEvent,
239 PreviewResizedEvent,
130240 PreviewFailedEvent,
131241 PreviewClosedEvent,
132242]);
packages/contracts/src/previewAutomation.ts+162-31
......@@ -1,7 +1,14 @@
11import { Schema } from "effect";
22
33import { 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";
512import { ProviderInstanceId } from "./providerInstance.ts";
613
714const BoundedUrl = Schema.String.check(Schema.isTrimmed())
......@@ -18,7 +25,8 @@ const OptionalTimeoutMs = Schema.optional(
1825 .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }),
1926).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 = [
2230 "status",
2331 "open",
2432 "navigate",
......@@ -31,7 +39,15 @@ export const PreviewAutomationOperation = Schema.Literals([
3139 "waitFor",
3240 "recordingStart",
3341 "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);
3551export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
3652
3753export const PreviewAutomationStatus = Schema.Struct({
......@@ -41,6 +57,10 @@ export const PreviewAutomationStatus = Schema.Struct({
4157 url: Schema.NullOr(Schema.String),
4258 title: Schema.NullOr(Schema.String),
4359 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),
4464});
4565export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
4666
......@@ -134,6 +154,80 @@ export const PreviewAutomationNavigateInput = Schema.Struct({
134154 });
135155export 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
137231const Locator = TrimmedNonEmptyString.annotate({
138232 description:
139233 "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({
411505});
412506export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type;
413507
414export const PreviewAutomationOwnerIdentity = Schema.Struct({
415 clientId: TrimmedNonEmptyString,
508export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
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,
416515 environmentId: EnvironmentId,
417 threadId: ThreadId,
418516});
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({
422 ...PreviewAutomationOwnerIdentity.fields,
423 tabId: Schema.NullOr(PreviewTabId),
424 visible: Schema.Boolean,
425 supportsAutomation: Schema.Boolean,
426 focusedAt: Schema.String,
529export const PreviewAutomationHostFocus = Schema.Struct({
530 ...PreviewAutomationHostIdentity.fields,
531 connectionId: PreviewAutomationConnectionId,
532 focused: Schema.Boolean,
427533});
428export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type;
534export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type;
429535
430536export const PreviewAutomationRequest = Schema.Struct({
431537 requestId: TrimmedNonEmptyString,
......@@ -437,7 +543,22 @@ export const PreviewAutomationRequest = Schema.Struct({
437543});
438544export 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
440559export const PreviewAutomationResponse = Schema.Struct({
560 clientId: PreviewAutomationClientId,
561 connectionId: PreviewAutomationConnectionId,
441562 requestId: TrimmedNonEmptyString,
442563 ok: Schema.Boolean,
443564 result: Schema.optional(Schema.Unknown),
......@@ -477,6 +598,7 @@ const PreviewAutomationScopeErrorFields = {
477598const PreviewAutomationRequestErrorFields = {
478599 ...PreviewAutomationScopeErrorFields,
479600 clientId: TrimmedNonEmptyString,
601 connectionId: PreviewAutomationConnectionId,
480602 requestId: TrimmedNonEmptyString,
481603 tabId: Schema.optional(PreviewTabId),
482604 timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),
......@@ -500,11 +622,12 @@ const PreviewAutomationOptionalRemoteDiagnosticFields = {
500622 cause: Schema.optional(Schema.Defect()),
501623};
502624
503export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()(
504 "PreviewAutomationNoFocusedOwnerError",
625export class PreviewAutomationNoAvailableHostError extends Schema.TaggedErrorClass<PreviewAutomationNoAvailableHostError>()(
626 "PreviewAutomationNoAvailableHostError",
505627 {
506628 ...PreviewAutomationScopeErrorFields,
507629 clientId: Schema.optional(TrimmedNonEmptyString),
630 connectionId: Schema.optional(PreviewAutomationConnectionId),
508631 requestId: Schema.optional(TrimmedNonEmptyString),
509632 tabId: Schema.optional(PreviewTabId),
510633 timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
......@@ -512,7 +635,7 @@ export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClas
512635 },
513636) {
514637 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}.`;
516639 return summary;
517640 }
518641}
......@@ -598,6 +721,26 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorCla
598721 }
599722}
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
601744export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()(
602745 "PreviewAutomationResultTooLargeError",
603746 {
......@@ -615,18 +758,6 @@ export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClas
615758 }
616759}
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
630761export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass<PreviewAutomationClientDisconnectedError>()(
631762 "PreviewAutomationClientDisconnectedError",
632763 PreviewAutomationRequestErrorFields,
......@@ -668,15 +799,15 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC
668799
669800export const PreviewAutomationError = Schema.Union([
670801 PreviewAutomationUnavailableError,
671 PreviewAutomationNoFocusedOwnerError,
802 PreviewAutomationNoAvailableHostError,
672803 PreviewAutomationUnsupportedClientError,
673804 PreviewAutomationTabNotFoundError,
674805 PreviewAutomationTimeoutError,
675806 PreviewAutomationControlInterruptedError,
676807 PreviewAutomationExecutionError,
677808 PreviewAutomationInvalidSelectorError,
809 PreviewAutomationTargetNotEditableError,
678810 PreviewAutomationResultTooLargeError,
679 PreviewAutomationHostNotConnectedError,
680811 PreviewAutomationClientDisconnectedError,
681812 PreviewAutomationRequestQueueClosedError,
682813 PreviewAutomationRemoteUnavailableError,
packages/contracts/src/rpc.ts+19-17
......@@ -103,14 +103,15 @@ import {
103103 PreviewOpenInput,
104104 PreviewRefreshInput,
105105 PreviewReportStatusInput,
106 PreviewResizeInput,
106107 PreviewSessionSnapshot,
107108} from "./preview.ts";
108109import {
109110 PreviewAutomationError,
110 PreviewAutomationOwner,
111 PreviewAutomationOwnerIdentity,
112 PreviewAutomationRequest,
111 PreviewAutomationHost,
112 PreviewAutomationHostFocus,
113113 PreviewAutomationResponse,
114 PreviewAutomationStreamEvent,
114115} from "./previewAutomation.ts";
115116import {
116117 ServerConfigStreamEvent,
......@@ -190,14 +191,14 @@ export const WS_METHODS = {
190191 // Preview methods
191192 previewOpen: "preview.open",
192193 previewNavigate: "preview.navigate",
194 previewResize: "preview.resize",
193195 previewRefresh: "preview.refresh",
194196 previewClose: "preview.close",
195197 previewList: "preview.list",
196198 previewReportStatus: "preview.reportStatus",
197199 previewAutomationConnect: "previewAutomation.connect",
198200 previewAutomationRespond: "previewAutomation.respond",
199 previewAutomationReportOwner: "previewAutomation.reportOwner",
200 previewAutomationClearOwner: "previewAutomation.clearOwner",
201 previewAutomationFocusHost: "previewAutomation.focusHost",
201202
202203 // Server meta
203204 serverGetConfig: "server.getConfig",
......@@ -528,6 +529,12 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, {
528529 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
529530});
530531
532export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, {
533 payload: PreviewResizeInput,
534 success: PreviewSessionSnapshot,
535 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
536});
537
531538export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, {
532539 payload: PreviewRefreshInput,
533540 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
......@@ -550,8 +557,8 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus,
550557});
551558
552559export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, {
553 payload: PreviewAutomationOwner,
554 success: PreviewAutomationRequest,
560 payload: PreviewAutomationHost,
561 success: PreviewAutomationStreamEvent,
555562 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
556563 stream: true,
557564});
......@@ -561,14 +568,9 @@ export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomati
561568 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
562569});
563570
564export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, {
565 payload: PreviewAutomationOwner,
566 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
567});
568
569export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, {
570 payload: PreviewAutomationOwnerIdentity,
571 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
571export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, {
572 payload: PreviewAutomationHostFocus,
573 error: EnvironmentAuthorizationError,
572574});
573575
574576export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {
......@@ -728,14 +730,14 @@ export const WsRpcGroup = RpcGroup.make(
728730 WsSubscribeTerminalMetadataRpc,
729731 WsPreviewOpenRpc,
730732 WsPreviewNavigateRpc,
733 WsPreviewResizeRpc,
731734 WsPreviewRefreshRpc,
732735 WsPreviewCloseRpc,
733736 WsPreviewListRpc,
734737 WsPreviewReportStatusRpc,
735738 WsPreviewAutomationConnectRpc,
736739 WsPreviewAutomationRespondRpc,
737 WsPreviewAutomationReportOwnerRpc,
738 WsPreviewAutomationClearOwnerRpc,
740 WsPreviewAutomationFocusHostRpc,
739741 WsSubscribePreviewEventsRpc,
740742 WsSubscribeDiscoveredLocalServersRpc,
741743 WsSubscribeServerConfigRpc,
packages/shared/package.json+4
......@@ -163,6 +163,10 @@
163163 "types": "./src/preview.ts",
164164 "import": "./src/preview.ts"
165165 },
166 "./previewViewport": {
167 "types": "./src/previewViewport.ts",
168 "import": "./src/previewViewport.ts"
169 },
166170 "./filePreview": {
167171 "types": "./src/filePreview.ts",
168172 "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) => {
253253 T3CODE_MODE: "web",
254254 T3CODE_NO_BROWSER: "0",
255255 T3CODE_HOST: "0.0.0.0",
256 VITE_DEV_SERVER_URL: "http://127.0.0.1:8526",
256257 VITE_WS_URL: "ws://localhost:13773",
257258 },
258259 serverOffset: 0,
scripts/dev-runner.ts+5-8
......@@ -158,12 +158,6 @@ const optionalIntegerConfig = (name: string): Config.Config<number | undefined>
158158 Config.option,
159159 Config.map((value) => Option.getOrUndefined(value)),
160160 );
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
167161const OffsetConfig = Config.all({
168162 portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"),
169163 devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"),
......@@ -621,8 +615,11 @@ const devRunnerCli = Command.make("dev-runner", {
621615 ),
622616 devUrl: Flag.string("dev-url").pipe(
623617 Flag.withSchema(Schema.URLFromString),
624 Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."),
625 Flag.withFallbackConfig(optionalUrlConfig("VITE_DEV_SERVER_URL")),
618 Flag.withDescription(
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),
626623 ),
627624 dryRun: Flag.boolean("dry-run").pipe(
628625 Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),