diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index ab76e4a3f24..6e2df118c34 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,4 +1,5 @@ import { it as effectIt } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -91,6 +92,7 @@ const layer = PreviewManager.layer.pipe( Layer.provideMerge(environmentLayer), Layer.provideMerge(fileSystemLayer), Layer.provideMerge(Path.layer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), ); const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 43b2259ead0..2d0360ef72c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -25,6 +25,7 @@ import type { PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { type BrowserWindow, @@ -379,6 +380,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function artifactDirectory: string, ) { const fileSystem = yield* FileSystem.FileSystem; + const hostPlatform = yield* HostProcessPlatform; const path = yield* Path.Path; const parentScope = yield* Scope.Scope; const context = yield* Effect.context(); @@ -2226,7 +2228,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function sendCleanup: SendCommand, ) { yield* prepareAutomationInput(send, false); - const keySequence = makePreviewAutomationKeySequence(input); + const keySequence = makePreviewAutomationKeySequence(input, { + isMac: hostPlatform === "darwin", + }); const previouslyFocused = yield* attempt( { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id }, () => webContents.getFocusedWebContents(), diff --git a/apps/desktop/src/preview/PreviewKeyboard.test.ts b/apps/desktop/src/preview/PreviewKeyboard.test.ts index 3acdcf0d1e5..7a9a7373fe3 100644 --- a/apps/desktop/src/preview/PreviewKeyboard.test.ts +++ b/apps/desktop/src/preview/PreviewKeyboard.test.ts @@ -42,7 +42,9 @@ describe("preview keyboard packets", () => { }); it("suppresses text and uses raw key-down for shortcuts", () => { - expect(makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown).toEqual({ + expect( + makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }, { isMac: true }).keyDown, + ).toEqual({ type: "rawKeyDown", key: "a", code: "KeyA", @@ -50,9 +52,20 @@ describe("preview keyboard packets", () => { windowsVirtualKeyCode: 65, location: 0, isKeypad: false, + commands: ["selectAll"], }); }); + it("maps common macOS editing shortcuts without changing other platforms", () => { + expect( + makePreviewAutomationKeySequence({ key: "z", modifiers: ["Shift", "Meta"] }, { isMac: true }) + .keyDown.commands, + ).toEqual(["redo"]); + expect( + makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown, + ).not.toHaveProperty("commands"); + }); + it("resolves shifted printable keys to their browser values", () => { const sequence = makePreviewAutomationKeySequence({ key: "1", modifiers: ["Shift"] }); expect(sequence.keyDown).toMatchObject({ diff --git a/apps/desktop/src/preview/PreviewKeyboard.ts b/apps/desktop/src/preview/PreviewKeyboard.ts index 6246cf5e558..0d231b86f4c 100644 --- a/apps/desktop/src/preview/PreviewKeyboard.ts +++ b/apps/desktop/src/preview/PreviewKeyboard.ts @@ -20,6 +20,7 @@ export interface PreviewAutomationKeyEvent { readonly isKeypad: boolean; readonly text?: string; readonly unmodifiedText?: string; + readonly commands?: ReadonlyArray; } export interface PreviewAutomationKeySequence { @@ -79,6 +80,42 @@ const PRINTABLE_KEYS: ReadonlyArray = [ { code: "Slash", key: "/", shiftedKey: "?", keyCode: 191 }, ]; +/** + * Chromium does not infer macOS editing commands from synthetic Meta chords. + * Keep the common browser editing/navigation shortcuts explicit so dispatched + * key events behave like their physical-key equivalents. + */ +const MAC_EDITING_COMMANDS: Readonly> = { + "Meta+Backspace": "deleteToBeginningOfLine", + "Meta+ArrowUp": "moveToBeginningOfDocument", + "Meta+ArrowDown": "moveToEndOfDocument", + "Meta+ArrowLeft": "moveToLeftEndOfLine", + "Meta+ArrowRight": "moveToRightEndOfLine", + "Shift+Meta+ArrowUp": "moveToBeginningOfDocumentAndModifySelection", + "Shift+Meta+ArrowDown": "moveToEndOfDocumentAndModifySelection", + "Shift+Meta+ArrowLeft": "moveToLeftEndOfLineAndModifySelection", + "Shift+Meta+ArrowRight": "moveToRightEndOfLineAndModifySelection", + "Meta+KeyA": "selectAll", + "Meta+KeyC": "copy", + "Meta+KeyX": "cut", + "Meta+KeyV": "paste", + "Meta+KeyZ": "undo", + "Shift+Meta+KeyZ": "redo", +}; +const SHORTCUT_MODIFIER_ORDER = ["Shift", "Control", "Alt", "Meta"] as const; + +const macEditingCommands = ( + code: string, + modifiers: PreviewAutomationPressInput["modifiers"], +): ReadonlyArray => { + const shortcut = [ + ...SHORTCUT_MODIFIER_ORDER.filter((modifier) => modifiers?.includes(modifier)), + code, + ].join("+"); + const command = MAC_EDITING_COMMANDS[shortcut]; + return command ? [command] : []; +}; + const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): number => (modifiers ?? []).reduce((value, modifier) => { switch (modifier) { @@ -136,12 +173,14 @@ function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition */ export function makePreviewAutomationKeySequence( input: PreviewAutomationPressInput, + options?: { readonly isMac?: boolean }, ): PreviewAutomationKeySequence { const definition = resolveKeyDefinition(input); const modifiers = modifierMask(input.modifiers); const suppressText = input.modifiers?.some((modifier) => modifier !== "Shift") ?? false; const text = suppressText ? "" : (definition.text ?? ""); const location = definition.location ?? 0; + const commands = options?.isMac ? macEditingCommands(definition.code, input.modifiers) : []; const shared = { key: definition.key, code: definition.code, @@ -156,6 +195,7 @@ export function makePreviewAutomationKeySequence( type: text ? "keyDown" : "rawKeyDown", ...shared, ...(text ? { text, unmodifiedText: text } : {}), + ...(commands.length > 0 ? { commands } : {}), }, keyUp: { type: "keyUp", ...shared }, signal: { kind: "key", key: definition.key, code: definition.code }, diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 2cd8494691f..94d8c13a3cb 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -51,6 +51,7 @@ import { PreviewAutomationTargetUnavailableError, PreviewAutomationViewportTimeoutError, } from "./previewAutomationErrors"; +import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness"; import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; import { createPreviewAutomationClientId } from "./previewAutomationClientId"; import { @@ -333,6 +334,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const input = request.input as PreviewAutomationOpenInput; let activeTabId = (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + let activeSnapshot = activeTabId + ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined) + : undefined; const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { @@ -349,17 +353,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const snapshot = result.value; applyPreviewServerSnapshot(threadRef, snapshot); activeTabId = snapshot.tabId; + activeSnapshot = snapshot; tabId = activeTabId; } if (input.show ?? true) { useRightPanelStore.getState().openBrowser(threadRef, activeTabId); } - await waitForDesktopOverlay( - threadRef, - request.requestId, - activeTabId, - request.timeoutMs, - ); + if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { + await waitForDesktopOverlay( + threadRef, + request.requestId, + activeTabId, + request.timeoutMs, + ); + } if (reusedExistingTab && input.url && previewBridge) { const resolution = resolveBrowserNavigationTarget(environmentId, { kind: "url", diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts new file mode 100644 index 00000000000..90de86f799d --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts @@ -0,0 +1,46 @@ +import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness"; + +const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId: "tab-1", + navStatus, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-26T00:00:00.000Z", +}); + +describe("preview automation open readiness", () => { + it("does not wait for a desktop overlay when opening an empty tab", () => { + expect( + previewAutomationOpenNeedsOverlay( + {} as PreviewAutomationOpenInput, + snapshot({ _tag: "Idle" }), + ), + ).toBe(false); + }); + + it("waits when an empty tab is immediately given a URL", () => { + expect( + previewAutomationOpenNeedsOverlay( + { url: "https://example.com" } as PreviewAutomationOpenInput, + snapshot({ _tag: "Idle" }), + ), + ).toBe(true); + }); + + it("waits for existing tabs that already have rendered content", () => { + expect( + previewAutomationOpenNeedsOverlay( + {} as PreviewAutomationOpenInput, + snapshot({ + _tag: "Success", + url: "https://example.com/", + title: "Example", + }), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts new file mode 100644 index 00000000000..416c2f87c64 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts @@ -0,0 +1,8 @@ +import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; + +export function previewAutomationOpenNeedsOverlay( + input: PreviewAutomationOpenInput, + snapshot: PreviewSessionSnapshot, +): boolean { + return input.url !== undefined || snapshot.navStatus._tag !== "Idle"; +}