Skip to content

Commit a1663cc

Browse files
authored
fix(preview): isolate keyboard focus during automation and indicate active focus (#647) (#710)
1 parent de96f49 commit a1663cc

9 files changed

Lines changed: 244 additions & 5 deletions

File tree

‎apps/desktop/src/preview/Manager.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15781578
}
15791579
const tabs = yield* SynchronizedRef.get(tabsRef);
15801580
if (tabs.has(tabId)) yield* update(tabId, { controller: "none" });
1581+
if (currentMainWindow && !currentMainWindow.isDestroyed()) {
1582+
yield* attempt({ operation: "focusMainWindow", tabId }, () => {
1583+
currentMainWindow?.webContents.focus();
1584+
}).pipe(Effect.ignore);
1585+
}
15811586
});
15821587
return yield* control.semaphore.withPermit(execute().pipe(Effect.onExit(finalize)));
15831588
});

‎apps/web/src/browser/HostedBrowserWebview.tsx‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
"use client";
22

3+
import { useAtomValue } from "@effect/atom-react";
4+
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
35
import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts";
46
import { useShallow } from "zustand/react/shallow";
57
import { useCallback, useEffect, useRef, useState } from "react";
68

79
import { previewBridge } from "~/components/preview/previewBridge";
810
import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
911
import { useClientSettingsHydrated } from "~/hooks/useSettings";
12+
import { dispatchSnapShotComposerFocus } from "~/lib/desktopSnapShot";
1013
import { cn, isMacPlatform } from "~/lib/utils";
14+
import { previewStateAtom } from "~/previewStateStore";
1115

1216
import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
1317
import { useActiveBrowserRecordingTabIds } from "./browserRecording";
@@ -93,8 +97,22 @@ export function HostedBrowserWebview(props: {
9397
(state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,
9498
);
9599
const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId);
100+
const threadKey = scopedThreadKey(threadRef);
101+
const previewState = useAtomValue(previewStateAtom(threadKey));
102+
const controller = previewState.desktopByTabId[tabId]?.controller ?? "none";
103+
const controllerRef = useRef(controller);
104+
controllerRef.current = controller;
96105
usePreviewBridge({ threadRef, tabId, runtimeTabId });
97106

107+
useEffect(() => {
108+
if (controller === "agent" && typeof document !== "undefined") {
109+
if (document.activeElement === webviewRef.current) {
110+
webviewRef.current?.blur();
111+
dispatchSnapShotComposerFocus();
112+
}
113+
}
114+
}, [controller]);
115+
98116
useEffect(() => {
99117
if (!clientSettingsHydrated) return;
100118
crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
@@ -156,13 +174,21 @@ export function HostedBrowserWebview(props: {
156174
}
157175
}, recovery.delayMs);
158176
};
177+
const handleFocus = () => {
178+
if (controllerRef.current === "agent") {
179+
webview.blur();
180+
dispatchSnapShotComposerFocus();
181+
}
182+
};
183+
webview.addEventListener("focus", handleFocus);
159184
webview.addEventListener("did-attach", register);
160185
webview.addEventListener("dom-ready", register);
161186
webview.addEventListener("render-process-gone", recoverGuest);
162187
register();
163188
return () => {
164189
disposed = true;
165190
if (recoveryTimeout !== null) clearTimeout(recoveryTimeout);
191+
webview.removeEventListener("focus", handleFocus);
166192
webview.removeEventListener("did-attach", register);
167193
webview.removeEventListener("dom-ready", register);
168194
webview.removeEventListener("render-process-gone", recoverGuest);

‎apps/web/src/components/preview/PreviewPanelShell.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "react";
99

1010
import { isElectron } from "~/env";
11+
import { usePreviewFocus } from "~/hooks/usePreviewFocus";
1112
import { useResizableWidth } from "~/hooks/useResizableWidth";
1213
import { cn } from "~/lib/utils";
1314

@@ -65,6 +66,7 @@ export function PreviewPanelShell(props: {
6566
children: ReactNode;
6667
}) {
6768
const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded";
69+
const previewFocused = usePreviewFocus();
6870
const isInline = props.mode === "inline";
6971
const collapsible = isInline && props.open !== undefined;
7072
const open = props.open ?? true;
@@ -142,6 +144,7 @@ export function PreviewPanelShell(props: {
142144
}
143145
data-preview-panel-mode={props.mode}
144146
data-preview-panel-maximized={maximized ? "true" : "false"}
147+
data-preview-focused={previewFocused ? "true" : undefined}
145148
>
146149
{isInline && !maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}
147150
<div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}>

‎apps/web/src/components/preview/PreviewView.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import {
4141
} from "~/previewMiniPlayerStore";
4242
import { useRightPanelStore } from "~/rightPanelStore";
4343

44+
import { usePreviewFocus } from "~/hooks/usePreviewFocus";
45+
import { cn } from "~/lib/utils";
4446
import { previewBridge } from "./previewBridge";
4547
import { subscribePreviewAction } from "./previewActionBus";
4648
import { openPreviewSession } from "./openPreviewSession";
@@ -705,10 +707,16 @@ export function PreviewView({
705707
});
706708
}, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]);
707709

710+
const previewFocused = usePreviewFocus();
711+
708712
return (
709713
<div
710-
className="flex min-h-0 flex-1 flex-col bg-background"
714+
className={cn(
715+
"flex min-h-0 flex-1 flex-col bg-background transition-shadow duration-150",
716+
previewFocused && "ring-1 ring-inset ring-ring/60",
717+
)}
711718
data-thread-key={scopedThreadKey(threadRef)}
719+
data-preview-focused={previewFocused ? "true" : undefined}
712720
>
713721
<PreviewChromeRow
714722
url={url}

‎apps/web/src/components/preview/usePreviewBridge.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
useFaviconProjectRefForThread,
1717
} from "~/browserFaviconStore";
1818
import { useBrowserPointerStore } from "~/browser/browserPointerStore";
19+
import { dispatchSnapShotComposerFocus } from "~/lib/desktopSnapShot";
1920
import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore";
2021
import { previewEnvironment } from "~/state/preview";
2122
import { usePreparedConnection } from "~/state/session";
@@ -61,13 +62,24 @@ export function usePreviewBridge(input: {
6162
const lastReportedUrl = useRef<string | null>(null);
6263
const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);
6364
const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null);
65+
const lastController = useRef<DesktopPreviewTabState["controller"] | null>(null);
6466
const handleStateChange = useEffectEvent(
6567
(changedTabId: string, state: DesktopPreviewTabState): void => {
6668
if (changedTabId !== runtimeTabId) return;
6769
if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {
6870
clearBrowserPointer(runtimeTabId);
6971
}
7072
lastDesktopNavStatus.current = state.navStatus;
73+
if (lastController.current === "agent" && state.controller !== "agent") {
74+
if (
75+
typeof document !== "undefined" &&
76+
document.activeElement?.tagName.toLowerCase() === "webview"
77+
) {
78+
(document.activeElement as HTMLElement).blur();
79+
dispatchSnapShotComposerFocus();
80+
}
81+
}
82+
lastController.current = state.controller;
7183
applyPreviewDesktopState(stableThreadRef, tabId, projectDesktopState(state));
7284
if (state.favicon) {
7385
recordFaviconForThread(stableThreadRef, state.favicon, projectRef, environmentHostname);
@@ -93,6 +105,7 @@ export function usePreviewBridge(input: {
93105
lastReportedUrl.current = null;
94106
lastReportedKind.current = null;
95107
lastDesktopNavStatus.current = null;
108+
lastController.current = null;
96109
return bridge.onStateChange(handleStateChange);
97110
}, [bridge, runtimeTabId, stableThreadRef, tabId]);
98111
useEffect(() => {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { afterEach, describe, expect, it } from "vite-plus/test";
2+
3+
import { subscribeToPreviewFocusChanges } from "./usePreviewFocus";
4+
5+
const originalWindow = globalThis.window;
6+
7+
afterEach(() => {
8+
if (originalWindow === undefined) {
9+
delete (globalThis as { window?: Window }).window;
10+
} else {
11+
globalThis.window = originalWindow;
12+
}
13+
});
14+
15+
describe("subscribeToPreviewFocusChanges", () => {
16+
it("notifies on focus and blur transitions until unsubscribed", () => {
17+
const listeners = new Map<string, Set<() => void>>();
18+
globalThis.window = {
19+
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
20+
const callbacks = listeners.get(type) ?? new Set<() => void>();
21+
callbacks.add(listener as () => void);
22+
listeners.set(type, callbacks);
23+
},
24+
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
25+
listeners.get(type)?.delete(listener as () => void);
26+
},
27+
} as unknown as Window & typeof globalThis;
28+
let notifications = 0;
29+
30+
const unsubscribe = subscribeToPreviewFocusChanges(() => {
31+
notifications += 1;
32+
});
33+
34+
for (const listener of listeners.get("focusin") ?? []) listener();
35+
for (const listener of listeners.get("focusout") ?? []) listener();
36+
for (const listener of listeners.get("focus") ?? []) listener();
37+
for (const listener of listeners.get("blur") ?? []) listener();
38+
expect(notifications).toBe(4);
39+
40+
unsubscribe();
41+
for (const listener of listeners.get("focusin") ?? []) listener();
42+
for (const listener of listeners.get("focusout") ?? []) listener();
43+
expect(notifications).toBe(4);
44+
});
45+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useSyncExternalStore } from "react";
2+
3+
import { isPreviewFocused } from "../lib/previewFocus";
4+
5+
export function subscribeToPreviewFocusChanges(listener: () => void): () => void {
6+
if (typeof window === "undefined") return () => {};
7+
window.addEventListener("focusin", listener, true);
8+
window.addEventListener("focusout", listener, true);
9+
window.addEventListener("focus", listener, true);
10+
window.addEventListener("blur", listener, true);
11+
return () => {
12+
window.removeEventListener("focusin", listener, true);
13+
window.removeEventListener("focusout", listener, true);
14+
window.removeEventListener("focus", listener, true);
15+
window.removeEventListener("blur", listener, true);
16+
};
17+
}
18+
19+
export function usePreviewFocus(): boolean {
20+
return useSyncExternalStore(subscribeToPreviewFocusChanges, isPreviewFocused, () => false);
21+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { afterEach, describe, expect, it } from "vite-plus/test";
2+
3+
import { isPreviewFocused } from "./previewFocus";
4+
5+
class MockHTMLElement {
6+
isConnected = false;
7+
tagName = "DIV";
8+
closestSelector: string | null = null;
9+
10+
closest(selector: string): MockHTMLElement | null {
11+
if (!this.isConnected) return null;
12+
if (this.closestSelector && selector.includes(this.closestSelector)) {
13+
return this;
14+
}
15+
return null;
16+
}
17+
}
18+
19+
const originalDocument = globalThis.document;
20+
const originalHTMLElement = globalThis.HTMLElement;
21+
22+
afterEach(() => {
23+
if (originalDocument === undefined) {
24+
delete (globalThis as { document?: Document }).document;
25+
} else {
26+
globalThis.document = originalDocument;
27+
}
28+
29+
if (originalHTMLElement === undefined) {
30+
delete (globalThis as { HTMLElement?: typeof HTMLElement }).HTMLElement;
31+
} else {
32+
globalThis.HTMLElement = originalHTMLElement;
33+
}
34+
});
35+
36+
describe("isPreviewFocused", () => {
37+
it("returns false when document is undefined or activeElement is null", () => {
38+
globalThis.document = undefined as unknown as Document;
39+
expect(isPreviewFocused()).toBe(false);
40+
41+
globalThis.document = { activeElement: null } as unknown as Document;
42+
expect(isPreviewFocused()).toBe(false);
43+
});
44+
45+
it("returns false for detached elements", () => {
46+
const detached = new MockHTMLElement();
47+
detached.isConnected = false;
48+
detached.tagName = "WEBVIEW";
49+
50+
globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
51+
globalThis.document = { activeElement: detached } as unknown as Document;
52+
53+
expect(isPreviewFocused()).toBe(false);
54+
});
55+
56+
it("returns true for attached webview", () => {
57+
const webview = new MockHTMLElement();
58+
webview.isConnected = true;
59+
webview.tagName = "WEBVIEW";
60+
61+
globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
62+
globalThis.document = { activeElement: webview } as unknown as Document;
63+
64+
expect(isPreviewFocused()).toBe(true);
65+
});
66+
67+
it("returns true for focus inside preview panel", () => {
68+
const input = new MockHTMLElement();
69+
input.isConnected = true;
70+
input.tagName = "INPUT";
71+
input.closestSelector = "data-preview-panel-mode";
72+
73+
globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
74+
globalThis.document = { activeElement: input } as unknown as Document;
75+
76+
expect(isPreviewFocused()).toBe(true);
77+
});
78+
79+
it("returns true for focus inside preview viewport", () => {
80+
const toolbarButton = new MockHTMLElement();
81+
toolbarButton.isConnected = true;
82+
toolbarButton.tagName = "BUTTON";
83+
toolbarButton.closestSelector = "data-preview-viewport";
84+
85+
globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
86+
globalThis.document = { activeElement: toolbarButton } as unknown as Document;
87+
88+
expect(isPreviewFocused()).toBe(true);
89+
});
90+
91+
it("returns true for focus inside preview mini player", () => {
92+
const playerControl = new MockHTMLElement();
93+
playerControl.isConnected = true;
94+
playerControl.tagName = "BUTTON";
95+
playerControl.closestSelector = "data-preview-mini-player";
96+
97+
globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
98+
globalThis.document = { activeElement: playerControl } as unknown as Document;
99+
100+
expect(isPreviewFocused()).toBe(true);
101+
});
102+
103+
it("returns false for elements outside preview", () => {
104+
const composer = new MockHTMLElement();
105+
composer.isConnected = true;
106+
composer.tagName = "TEXTAREA";
107+
108+
globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
109+
globalThis.document = { activeElement: composer } as unknown as Document;
110+
111+
expect(isPreviewFocused()).toBe(false);
112+
});
113+
});

‎apps/web/src/lib/previewFocus.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
/**
22
* Returns true when the user's keyboard focus is somewhere inside the
3-
* preview panel (URL bar, chrome buttons, or — once detected via Electron
4-
* `<webview>` focus events — the embedded page).
3+
* preview panel (URL bar, chrome buttons, the embedded page via Electron
4+
* `<webview>`, or the floating preview mini-player).
55
*
66
* Used by the global keybinding handler to gate `preview.refresh` and
7-
* `preview.focusUrl` to only fire while the preview owns focus.
7+
* `preview.focusUrl` to only fire while the preview owns focus, and by the
8+
* preview chrome/shell to render focus indicators.
89
*/
910
export function isPreviewFocused(): boolean {
11+
if (typeof document === "undefined" || typeof HTMLElement === "undefined") return false;
1012
const activeElement = document.activeElement;
1113
if (!(activeElement instanceof HTMLElement)) return false;
1214
if (!activeElement.isConnected) return false;
1315
if (activeElement.tagName.toLowerCase() === "webview") return true;
14-
return activeElement.closest("[data-preview-panel-mode]") !== null;
16+
if (activeElement.closest("[data-preview-panel-mode]") !== null) return true;
17+
if (activeElement.closest("[data-preview-viewport]") !== null) return true;
18+
if (activeElement.closest("[data-preview-mini-player]") !== null) return true;
19+
return false;
1520
}

0 commit comments

Comments
 (0)