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 @@
11import { it as effectIt } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
23import * as Cause from "effect/Cause";
34import * as Effect from "effect/Effect";
45import * as Exit from "effect/Exit";
......@@ -91,6 +92,7 @@ const layer = PreviewManager.layer.pipe(
9192 Layer.provideMerge(environmentLayer),
9293 Layer.provideMerge(fileSystemLayer),
9394 Layer.provideMerge(Path.layer),
95 Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")),
9496);
9597const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError);
9698
apps/desktop/src/preview/Manager.ts+5-1
......@@ -25,6 +25,7 @@ import type {
2525 PreviewAutomationTypeInput,
2626 PreviewAutomationWaitForInput,
2727} from "@t3tools/contracts";
28import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2829import { normalizePreviewUrl } from "@t3tools/shared/preview";
2930import {
3031 type BrowserWindow,
......@@ -379,6 +380,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
379380 artifactDirectory: string,
380381) {
381382 const fileSystem = yield* FileSystem.FileSystem;
383 const hostPlatform = yield* HostProcessPlatform;
382384 const path = yield* Path.Path;
383385 const parentScope = yield* Scope.Scope;
384386 const context = yield* Effect.context<never>();
......@@ -2226,7 +2228,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
22262228 sendCleanup: SendCommand,
22272229 ) {
22282230 yield* prepareAutomationInput(send, false);
2229 const keySequence = makePreviewAutomationKeySequence(input);
2231 const keySequence = makePreviewAutomationKeySequence(input, {
2232 isMac: hostPlatform === "darwin",
2233 });
22302234 const previouslyFocused = yield* attempt(
22312235 { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id },
22322236 () => webContents.getFocusedWebContents(),
apps/desktop/src/preview/PreviewKeyboard.test.ts+14-1
......@@ -42,7 +42,9 @@ describe("preview keyboard packets", () => {
4242 });
4343
4444 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({
4648 type: "rawKeyDown",
4749 key: "a",
4850 code: "KeyA",
......@@ -50,9 +52,20 @@ describe("preview keyboard packets", () => {
5052 windowsVirtualKeyCode: 65,
5153 location: 0,
5254 isKeypad: false,
55 commands: ["selectAll"],
5356 });
5457 });
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
5669 it("resolves shifted printable keys to their browser values", () => {
5770 const sequence = makePreviewAutomationKeySequence({ key: "1", modifiers: ["Shift"] });
5871 expect(sequence.keyDown).toMatchObject({
apps/desktop/src/preview/PreviewKeyboard.ts+40
......@@ -20,6 +20,7 @@ export interface PreviewAutomationKeyEvent {
2020 readonly isKeypad: boolean;
2121 readonly text?: string;
2222 readonly unmodifiedText?: string;
23 readonly commands?: ReadonlyArray<string>;
2324}
2425
2526export interface PreviewAutomationKeySequence {
......@@ -79,6 +80,42 @@ const PRINTABLE_KEYS: ReadonlyArray<KeyDefinition> = [
7980 { code: "Slash", key: "/", shiftedKey: "?", keyCode: 191 },
8081];
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
82119const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): number =>
83120 (modifiers ?? []).reduce((value, modifier) => {
84121 switch (modifier) {
......@@ -136,12 +173,14 @@ function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition
136173 */
137174export function makePreviewAutomationKeySequence(
138175 input: PreviewAutomationPressInput,
176 options?: { readonly isMac?: boolean },
139177): PreviewAutomationKeySequence {
140178 const definition = resolveKeyDefinition(input);
141179 const modifiers = modifierMask(input.modifiers);
142180 const suppressText = input.modifiers?.some((modifier) => modifier !== "Shift") ?? false;
143181 const text = suppressText ? "" : (definition.text ?? "");
144182 const location = definition.location ?? 0;
183 const commands = options?.isMac ? macEditingCommands(definition.code, input.modifiers) : [];
145184 const shared = {
146185 key: definition.key,
147186 code: definition.code,
......@@ -156,6 +195,7 @@ export function makePreviewAutomationKeySequence(
156195 type: text ? "keyDown" : "rawKeyDown",
157196 ...shared,
158197 ...(text ? { text, unmodifiedText: text } : {}),
198 ...(commands.length > 0 ? { commands } : {}),
159199 },
160200 keyUp: { type: "keyUp", ...shared },
161201 signal: { kind: "key", key: definition.key, code: definition.code },
apps/web/src/components/preview/PreviewAutomationHosts.tsx+13-6
......@@ -51,6 +51,7 @@ import {
5151 PreviewAutomationTargetUnavailableError,
5252 PreviewAutomationViewportTimeoutError,
5353} from "./previewAutomationErrors";
54import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness";
5455import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
5556import { createPreviewAutomationClientId } from "./previewAutomationClientId";
5657import {
......@@ -333,6 +334,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
333334 const input = request.input as PreviewAutomationOpenInput;
334335 let activeTabId =
335336 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;
337 let activeSnapshot = activeTabId
338 ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined)
339 : undefined;
336340 const reusedExistingTab = activeTabId !== null;
337341 tabId = activeTabId;
338342 if (!activeTabId) {
......@@ -349,17 +353,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
349353 const snapshot = result.value;
350354 applyPreviewServerSnapshot(threadRef, snapshot);
351355 activeTabId = snapshot.tabId;
356 activeSnapshot = snapshot;
352357 tabId = activeTabId;
353358 }
354359 if (input.show ?? true) {
355360 useRightPanelStore.getState().openBrowser(threadRef, activeTabId);
356361 }
357 await waitForDesktopOverlay(
358 threadRef,
359 request.requestId,
360 activeTabId,
361 request.timeoutMs,
362 );
362 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
363 await waitForDesktopOverlay(
364 threadRef,
365 request.requestId,
366 activeTabId,
367 request.timeoutMs,
368 );
369 }
363370 if (reusedExistingTab && input.url && previewBridge) {
364371 const resolution = resolveBrowserNavigationTarget(environmentId, {
365372 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}