Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/desktop/src/preview/GuestInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,59 @@ describe("preview guest input", () => {
},
);

it.each([{ userClicksStuckTab: false }, { userClicksStuckTab: true }])(
"keeps the right focus when a sibling press finishes beside a stuck one, user clicks: $userClicksStuckTab",
async ({ userClicksStuckTab }) => {
const document = { activeElement: {} as object };
const composer = {
isConnected: true,
focus: () => {
document.activeElement = composer;
},
};
document.activeElement = composer;
const context = NodeVM.createContext({ document });
const host = {
isDestroyed: () => false,
executeJavaScript: async (script: string) => NodeVM.runInContext(script, context),
};
const previews = new Map(
[7, 8].map((id) => [id, { tagName: "WEBVIEW", getWebContentsId: () => id }]),
);
const stuckPressFocused = Promise.withResolvers<void>();
const stuckPressEnds = Promise.withResolvers<void>();
const press = (id: number) => {
const debuggee = {
sendCommand: async () => {
document.activeElement = previews.get(id)!;
if (id === 7) {
stuckPressFocused.resolve();
await stuckPressEnds.promise;
} else if (userClicksStuckTab) {
document.activeElement = previews.get(7)!;
}
return {};
},
};
return sendPageCommand(
{ id, focusedFrame: null, hostWebContents: host } as unknown as Electron.WebContents,
debuggee as unknown as Electron.Debugger,
"Input.dispatchMouseEvent",
{ type: "mousePressed", x: 10, y: 10, button: "left" },
);
};
// A press held open, as by a page's confirm() on mousedown, must not block or win focus.
const stuck = press(7);
await stuckPressFocused.promise;
await press(8);
const expected = userClicksStuckTab ? previews.get(7) : composer;
expect(document.activeElement).toBe(expected);
stuckPressEnds.resolve();
await stuck;
expect(Object.keys(context)).toEqual(["document"]);
},
);

it.each([
{
method: "Input.dispatchMouseEvent",
Expand Down
35 changes: 25 additions & 10 deletions apps/desktop/src/preview/GuestInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ const evaluate = async (debuggee: Electron.Debugger, expression: string) => {
return reply.result?.value;
};

let focusSequence = 0;
/** Host focus shared by the presses in flight in one window, so a later press keeps the field an earlier one saved. */
const HOST_FOCUS = "globalThis.__t3PreviewFocus";

/** A CDP pointer press focuses the guest's native view even with focus emulation enabled. */
const sendPointerPress = async (
Expand All @@ -106,22 +107,36 @@ const sendPointerPress = async (
if (!host || host.isDestroyed() || webContents.focusedFrame) {
return debuggee.sendCommand(method, params, sessionId);
}
const key = JSON.stringify(`__t3PreviewFocus${++focusSequence}`);
// Focus in a webview with a press in flight came from that press, so keep the earlier save.
await host.executeJavaScript(`(() => {
const state = (${HOST_FOCUS} ??= { saved: null, presses: [] });
const active = document.activeElement;
if (!active || active === document.body || active.tagName === "WEBVIEW") return;
globalThis[${key}] = () => {
delete globalThis[${key}];
const current = document.activeElement;
if (active.isConnected && current?.tagName === "WEBVIEW" &&
current.getWebContentsId() === ${webContents.id}) active.focus({ preventScroll: true });
};
let pressed = false;
try {
pressed = active?.tagName === "WEBVIEW" && state.presses.includes(active.getWebContentsId());
} catch {}
if (!pressed) {
state.saved = active && active !== document.body && active.tagName !== "WEBVIEW" ? active : null;
}
state.presses.push(${webContents.id});
})()`);
try {
return await debuggee.sendCommand(method, params, sessionId);
} finally {
if (!host.isDestroyed()) {
await host.executeJavaScript(`globalThis[${key}]?.()`);
// Membership check: a host reload starts a new state without this press.
await host.executeJavaScript(`(() => {
const state = ${HOST_FOCUS};
const index = state ? state.presses.indexOf(${webContents.id}) : -1;
if (index < 0) return;
state.presses.splice(index, 1);
if (state.presses.length === 0) delete ${HOST_FOCUS};
const current = document.activeElement;
try {
if (state.saved?.isConnected && current?.tagName === "WEBVIEW" &&
current.getWebContentsId() === ${webContents.id}) state.saved.focus({ preventScroll: true });
} catch {}
})()`);
}
}
};
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/preview/agentOpenedPreviews.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,14 @@ describe("revealServerTabs", () => {
expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).not.toBe(
browserSurfaceId,
);
const userActionRevision = useRightPanelStore.getState().getUserActionRevision(ref);
revealServerTabs(ref, [tab("tab_a", true)], { autoShowFloatingPreview: true });
expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).toBe(
browserSurfaceId,
);
expect(floatingTabId()).toBeNull();
// A user action moves keyboard focus into the panel; an agent reveal must not.
expect(useRightPanelStore.getState().getUserActionRevision(ref)).toBe(userActionRevision);
});
});

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/preview/agentOpenedPreviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function revealServerTabs(
(surface) => surface.kind === "preview" && surface.resourceId === session.tabId,
);
if (surface && force) {
useRightPanelStore.getState().activateSurface(threadRef, surface.id);
useRightPanelStore.getState().revealSurface(threadRef, surface.id);
} else if (!surface) {
usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(session.tabId));
}
Expand Down
17 changes: 12 additions & 5 deletions apps/web/src/rightPanelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ interface RightPanelStoreState {
activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
/** Agent-driven activation. Not a user action, so it does not move keyboard focus. */
revealSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;
closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;
Expand Down Expand Up @@ -451,6 +453,11 @@ const userActionOnThread = (
: {}),
});

const activated = (current: ThreadRightPanelState, surfaceId: string): ThreadRightPanelState =>
current.surfaces.some((surface) => surface.id === surfaceId)
? { ...current, isOpen: true, activeSurfaceId: surfaceId }
: current;

const closeAction = (
state: RightPanelStoreState,
threadKey: string,
Expand Down Expand Up @@ -928,11 +935,11 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
),
activateSurface: (ref, surfaceId) =>
set((state) =>
userAction(state, scopedThreadKey(ref), (current) =>
current.surfaces.some((surface) => surface.id === surfaceId)
? { ...current, isOpen: true, activeSurfaceId: surfaceId }
: current,
),
userAction(state, scopedThreadKey(ref), (current) => activated(current, surfaceId)),
),
revealSurface: (ref, surfaceId) =>
set((state) =>
automaticUpdate(state, scopedThreadKey(ref), (current) => activated(current, surfaceId)),
),
closeSurface: (ref, surfaceId) =>
set((state) =>
Expand Down
Loading