authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-25 20:08:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-25 20:08:27-07:00
loge9ed70c5b935d8653c887c6a2f5c1f93013579d4
tree41d0c0f3584c439bc2cabb124f888b3e0584ccba
parentffae5410e8f8ff5ac72330e0b63535b89f9fd555
signature Signed by PGP key B5690EEEBB952194

[codex] Fix preview automation edge cases (#3561)

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

7 files changed, 128 insertions(+), 8 deletions(-)

apps/desktop/src/preview/Manager.test.ts+2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { it as effectIt } from "@effect/vitest";1import { it as effectIt } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
...@@ -91,6 +92,7 @@ const layer = PreviewManager.layer.pipe(...@@ -91,6 +92,7 @@ const layer = PreviewManager.layer.pipe(
91 Layer.provideMerge(environmentLayer),92 Layer.provideMerge(environmentLayer),
92 Layer.provideMerge(fileSystemLayer),93 Layer.provideMerge(fileSystemLayer),
93 Layer.provideMerge(Path.layer),94 Layer.provideMerge(Path.layer),
95 Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")),
94);96);
95const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError);97const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError);
9698
apps/desktop/src/preview/Manager.ts+5-1
...@@ -25,6 +25,7 @@ import type {...@@ -25,6 +25,7 @@ import type {
25 PreviewAutomationTypeInput,25 PreviewAutomationTypeInput,
26 PreviewAutomationWaitForInput,26 PreviewAutomationWaitForInput,
27} from "@t3tools/contracts";27} from "@t3tools/contracts";
28import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
28import { normalizePreviewUrl } from "@t3tools/shared/preview";29import { normalizePreviewUrl } from "@t3tools/shared/preview";
29import {30import {
30 type BrowserWindow,31 type BrowserWindow,
...@@ -379,6 +380,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -379,6 +380,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
379 artifactDirectory: string,380 artifactDirectory: string,
380) {381) {
381 const fileSystem = yield* FileSystem.FileSystem;382 const fileSystem = yield* FileSystem.FileSystem;
383 const hostPlatform = yield* HostProcessPlatform;
382 const path = yield* Path.Path;384 const path = yield* Path.Path;
383 const parentScope = yield* Scope.Scope;385 const parentScope = yield* Scope.Scope;
384 const context = yield* Effect.context<never>();386 const context = yield* Effect.context<never>();
...@@ -2226,7 +2228,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2226,7 +2228,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2226 sendCleanup: SendCommand,2228 sendCleanup: SendCommand,
2227 ) {2229 ) {
2228 yield* prepareAutomationInput(send, false);2230 yield* prepareAutomationInput(send, false);
2229 const keySequence = makePreviewAutomationKeySequence(input);2231 const keySequence = makePreviewAutomationKeySequence(input, {
2232 isMac: hostPlatform === "darwin",
2233 });
2230 const previouslyFocused = yield* attempt(2234 const previouslyFocused = yield* attempt(
2231 { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id },2235 { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id },
2232 () => webContents.getFocusedWebContents(),2236 () => webContents.getFocusedWebContents(),
apps/desktop/src/preview/PreviewKeyboard.test.ts+14-1
...@@ -42,7 +42,9 @@ describe("preview keyboard packets", () => {...@@ -42,7 +42,9 @@ describe("preview keyboard packets", () => {
42 });42 });
4343
44 it("suppresses text and uses raw key-down for shortcuts", () => {44 it("suppresses text and uses raw key-down for shortcuts", () => {
45 expect(makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown).toEqual({45 expect(
46 makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }, { isMac: true }).keyDown,
47 ).toEqual({
46 type: "rawKeyDown",48 type: "rawKeyDown",
47 key: "a",49 key: "a",
48 code: "KeyA",50 code: "KeyA",
...@@ -50,9 +52,20 @@ describe("preview keyboard packets", () => {...@@ -50,9 +52,20 @@ describe("preview keyboard packets", () => {
50 windowsVirtualKeyCode: 65,52 windowsVirtualKeyCode: 65,
51 location: 0,53 location: 0,
52 isKeypad: false,54 isKeypad: false,
55 commands: ["selectAll"],
53 });56 });
54 });57 });
5558
59 it("maps common macOS editing shortcuts without changing other platforms", () => {
60 expect(
61 makePreviewAutomationKeySequence({ key: "z", modifiers: ["Shift", "Meta"] }, { isMac: true })
62 .keyDown.commands,
63 ).toEqual(["redo"]);
64 expect(
65 makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown,
66 ).not.toHaveProperty("commands");
67 });
68
56 it("resolves shifted printable keys to their browser values", () => {69 it("resolves shifted printable keys to their browser values", () => {
57 const sequence = makePreviewAutomationKeySequence({ key: "1", modifiers: ["Shift"] });70 const sequence = makePreviewAutomationKeySequence({ key: "1", modifiers: ["Shift"] });
58 expect(sequence.keyDown).toMatchObject({71 expect(sequence.keyDown).toMatchObject({
apps/desktop/src/preview/PreviewKeyboard.ts+40
...@@ -20,6 +20,7 @@ export interface PreviewAutomationKeyEvent {...@@ -20,6 +20,7 @@ export interface PreviewAutomationKeyEvent {
20 readonly isKeypad: boolean;20 readonly isKeypad: boolean;
21 readonly text?: string;21 readonly text?: string;
22 readonly unmodifiedText?: string;22 readonly unmodifiedText?: string;
23 readonly commands?: ReadonlyArray<string>;
23}24}
2425
25export interface PreviewAutomationKeySequence {26export interface PreviewAutomationKeySequence {
...@@ -79,6 +80,42 @@ const PRINTABLE_KEYS: ReadonlyArray<KeyDefinition> = [...@@ -79,6 +80,42 @@ const PRINTABLE_KEYS: ReadonlyArray<KeyDefinition> = [
79 { code: "Slash", key: "/", shiftedKey: "?", keyCode: 191 },80 { code: "Slash", key: "/", shiftedKey: "?", keyCode: 191 },
80];81];
8182
83/**
84 * Chromium does not infer macOS editing commands from synthetic Meta chords.
85 * Keep the common browser editing/navigation shortcuts explicit so dispatched
86 * key events behave like their physical-key equivalents.
87 */
88const MAC_EDITING_COMMANDS: Readonly<Record<string, string>> = {
89 "Meta+Backspace": "deleteToBeginningOfLine",
90 "Meta+ArrowUp": "moveToBeginningOfDocument",
91 "Meta+ArrowDown": "moveToEndOfDocument",
92 "Meta+ArrowLeft": "moveToLeftEndOfLine",
93 "Meta+ArrowRight": "moveToRightEndOfLine",
94 "Shift+Meta+ArrowUp": "moveToBeginningOfDocumentAndModifySelection",
95 "Shift+Meta+ArrowDown": "moveToEndOfDocumentAndModifySelection",
96 "Shift+Meta+ArrowLeft": "moveToLeftEndOfLineAndModifySelection",
97 "Shift+Meta+ArrowRight": "moveToRightEndOfLineAndModifySelection",
98 "Meta+KeyA": "selectAll",
99 "Meta+KeyC": "copy",
100 "Meta+KeyX": "cut",
101 "Meta+KeyV": "paste",
102 "Meta+KeyZ": "undo",
103 "Shift+Meta+KeyZ": "redo",
104};
105const SHORTCUT_MODIFIER_ORDER = ["Shift", "Control", "Alt", "Meta"] as const;
106
107const macEditingCommands = (
108 code: string,
109 modifiers: PreviewAutomationPressInput["modifiers"],
110): ReadonlyArray<string> => {
111 const shortcut = [
112 ...SHORTCUT_MODIFIER_ORDER.filter((modifier) => modifiers?.includes(modifier)),
113 code,
114 ].join("+");
115 const command = MAC_EDITING_COMMANDS[shortcut];
116 return command ? [command] : [];
117};
118
82const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): number =>119const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): number =>
83 (modifiers ?? []).reduce((value, modifier) => {120 (modifiers ?? []).reduce((value, modifier) => {
84 switch (modifier) {121 switch (modifier) {
...@@ -136,12 +173,14 @@ function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition...@@ -136,12 +173,14 @@ function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition
136 */173 */
137export function makePreviewAutomationKeySequence(174export function makePreviewAutomationKeySequence(
138 input: PreviewAutomationPressInput,175 input: PreviewAutomationPressInput,
176 options?: { readonly isMac?: boolean },
139): PreviewAutomationKeySequence {177): PreviewAutomationKeySequence {
140 const definition = resolveKeyDefinition(input);178 const definition = resolveKeyDefinition(input);
141 const modifiers = modifierMask(input.modifiers);179 const modifiers = modifierMask(input.modifiers);
142 const suppressText = input.modifiers?.some((modifier) => modifier !== "Shift") ?? false;180 const suppressText = input.modifiers?.some((modifier) => modifier !== "Shift") ?? false;
143 const text = suppressText ? "" : (definition.text ?? "");181 const text = suppressText ? "" : (definition.text ?? "");
144 const location = definition.location ?? 0;182 const location = definition.location ?? 0;
183 const commands = options?.isMac ? macEditingCommands(definition.code, input.modifiers) : [];
145 const shared = {184 const shared = {
146 key: definition.key,185 key: definition.key,
147 code: definition.code,186 code: definition.code,
...@@ -156,6 +195,7 @@ export function makePreviewAutomationKeySequence(...@@ -156,6 +195,7 @@ export function makePreviewAutomationKeySequence(
156 type: text ? "keyDown" : "rawKeyDown",195 type: text ? "keyDown" : "rawKeyDown",
157 ...shared,196 ...shared,
158 ...(text ? { text, unmodifiedText: text } : {}),197 ...(text ? { text, unmodifiedText: text } : {}),
198 ...(commands.length > 0 ? { commands } : {}),
159 },199 },
160 keyUp: { type: "keyUp", ...shared },200 keyUp: { type: "keyUp", ...shared },
161 signal: { kind: "key", key: definition.key, code: definition.code },201 signal: { kind: "key", key: definition.key, code: definition.code },
apps/web/src/components/preview/PreviewAutomationHosts.tsx+13-6
...@@ -51,6 +51,7 @@ import {...@@ -51,6 +51,7 @@ import {
51 PreviewAutomationTargetUnavailableError,51 PreviewAutomationTargetUnavailableError,
52 PreviewAutomationViewportTimeoutError,52 PreviewAutomationViewportTimeoutError,
53} from "./previewAutomationErrors";53} from "./previewAutomationErrors";
54import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness";
54import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";55import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
55import { createPreviewAutomationClientId } from "./previewAutomationClientId";56import { createPreviewAutomationClientId } from "./previewAutomationClientId";
56import {57import {
...@@ -333,6 +334,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -333,6 +334,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
333 const input = request.input as PreviewAutomationOpenInput;334 const input = request.input as PreviewAutomationOpenInput;
334 let activeTabId =335 let activeTabId =
335 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;336 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;
337 let activeSnapshot = activeTabId
338 ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined)
339 : undefined;
336 const reusedExistingTab = activeTabId !== null;340 const reusedExistingTab = activeTabId !== null;
337 tabId = activeTabId;341 tabId = activeTabId;
338 if (!activeTabId) {342 if (!activeTabId) {
...@@ -349,17 +353,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -349,17 +353,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
349 const snapshot = result.value;353 const snapshot = result.value;
350 applyPreviewServerSnapshot(threadRef, snapshot);354 applyPreviewServerSnapshot(threadRef, snapshot);
351 activeTabId = snapshot.tabId;355 activeTabId = snapshot.tabId;
356 activeSnapshot = snapshot;
352 tabId = activeTabId;357 tabId = activeTabId;
353 }358 }
354 if (input.show ?? true) {359 if (input.show ?? true) {
355 useRightPanelStore.getState().openBrowser(threadRef, activeTabId);360 useRightPanelStore.getState().openBrowser(threadRef, activeTabId);
356 }361 }
357 await waitForDesktopOverlay(362 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
358 threadRef,363 await waitForDesktopOverlay(
359 request.requestId,364 threadRef,
360 activeTabId,365 request.requestId,
361 request.timeoutMs,366 activeTabId,
362 );367 request.timeoutMs,
368 );
369 }
363 if (reusedExistingTab && input.url && previewBridge) {370 if (reusedExistingTab && input.url && previewBridge) {
364 const resolution = resolveBrowserNavigationTarget(environmentId, {371 const resolution = resolveBrowserNavigationTarget(environmentId, {
365 kind: "url",372 kind: "url",
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts created+46
...@@ -0,0 +1,46 @@
1import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness";
5
6const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({
7 threadId: "thread-1",
8 tabId: "tab-1",
9 navStatus,
10 canGoBack: false,
11 canGoForward: false,
12 updatedAt: "2026-06-26T00:00:00.000Z",
13});
14
15describe("preview automation open readiness", () => {
16 it("does not wait for a desktop overlay when opening an empty tab", () => {
17 expect(
18 previewAutomationOpenNeedsOverlay(
19 {} as PreviewAutomationOpenInput,
20 snapshot({ _tag: "Idle" }),
21 ),
22 ).toBe(false);
23 });
24
25 it("waits when an empty tab is immediately given a URL", () => {
26 expect(
27 previewAutomationOpenNeedsOverlay(
28 { url: "https://example.com" } as PreviewAutomationOpenInput,
29 snapshot({ _tag: "Idle" }),
30 ),
31 ).toBe(true);
32 });
33
34 it("waits for existing tabs that already have rendered content", () => {
35 expect(
36 previewAutomationOpenNeedsOverlay(
37 {} as PreviewAutomationOpenInput,
38 snapshot({
39 _tag: "Success",
40 url: "https://example.com/",
41 title: "Example",
42 }),
43 ),
44 ).toBe(true);
45 });
46});
apps/web/src/components/preview/previewAutomationOpenReadiness.ts created+8
...@@ -0,0 +1,8 @@
1import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts";
2
3export function previewAutomationOpenNeedsOverlay(
4 input: PreviewAutomationOpenInput,
5 snapshot: PreviewSessionSnapshot,
6): boolean {
7 return input.url !== undefined || snapshot.navStatus._tag !== "Idle";
8}