Skip to content

Commit 0df5fde

Browse files
committed
fix(browser): preserve native file pickers in desktop previews
An unconditional Playwright filechooser subscription suppresses the OS picker in the native desktop view, which has no streamed uploader to answer it. Intercept only during agent actions or streamed control, and restore native picking after actions finish or control is released. Cover both upload paths and restoration after a failed agent action.
1 parent 13aa882 commit 0df5fde

2 files changed

Lines changed: 106 additions & 1 deletion

File tree

‎apps/server/src/preview/ServerBrowser.test.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ function makeSession(frame?: ReturnType<typeof makeFrame>) {
107107

108108
function makeContext(onClose?: (context: BrowserContext) => void) {
109109
const events = new NodeEvents.EventEmitter();
110+
const nativeFileChooser = vi.fn();
111+
const setChooserFiles = vi.fn(async (_files: unknown) => {});
110112
const sessions: ReturnType<typeof makeSession>[] = [];
111113
let url = "about:blank";
112114
let viewport = { width: 1280, height: 800 };
@@ -117,6 +119,19 @@ function makeContext(onClose?: (context: BrowserContext) => void) {
117119
once: (name: string, callback: (...args: unknown[]) => void) => events.once(name, callback),
118120
off: (name: string, callback: (...args: unknown[]) => void) => events.off(name, callback),
119121
emit: (name: string, ...args: unknown[]) => events.emit(name, ...args),
122+
nativeFileChooser,
123+
setChooserFiles,
124+
openFileChooser: () => {
125+
if (
126+
!events.emit("filechooser", {
127+
isMultiple: () => false,
128+
element: () => ({ getAttribute: async () => ".csv" }),
129+
setFiles: setChooserFiles,
130+
})
131+
) {
132+
nativeFileChooser();
133+
}
134+
},
120135
emitAsync: (name: string, ...args: unknown[]) =>
121136
Promise.all(events.listeners(name).map((listener) => listener(...args))),
122137
setDefaultTimeout: vi.fn(),
@@ -1395,6 +1410,75 @@ it.live("drives the desktop's own page for a tab the desktop renders", () =>
13951410
).pipe(Effect.provide(layer)),
13961411
);
13971412

1413+
it.live("desktop file pickers stay native outside agent actions and remote control", () =>
1414+
Effect.scoped(
1415+
Effect.gen(function* () {
1416+
const browser = yield* ServerBrowser.ServerBrowser;
1417+
const broker = yield* Broker.PreviewAutomationBroker;
1418+
const manager = yield* Manager.PreviewManager;
1419+
yield* Effect.yieldNow;
1420+
desktopRendersNext = true;
1421+
const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
1422+
const tabId = PreviewTabId.make(opened.tabId);
1423+
const observer = yield* browser.attachViewer(viewerInput(tabId, false));
1424+
const page = desktopConnections[0]!.context.page;
1425+
page.openFileChooser();
1426+
expect(page.nativeFileChooser).toHaveBeenCalledTimes(1);
1427+
1428+
const status = () =>
1429+
broker.invoke<PreviewAutomationStatus>({ scope, tabId, operation: "status", input: {} });
1430+
expect((yield* status()).fileChooser).toBeNull();
1431+
const remote = yield* browser.attachViewer(viewerInput(tabId, true));
1432+
page.openFileChooser();
1433+
let offered = yield* Queue.take(remote.output);
1434+
while (offered._tag !== "fileChooser") offered = yield* Queue.take(remote.output);
1435+
const file = { name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a,b") };
1436+
expect(
1437+
yield* browser.answerFileChooser({
1438+
threadId: scope.thread.threadId,
1439+
tabId,
1440+
chooserId: offered.id,
1441+
files: [file],
1442+
}),
1443+
).toBe(true);
1444+
expect(page.setChooserFiles).toHaveBeenLastCalledWith([file]);
1445+
expect(page.nativeFileChooser).toHaveBeenCalledTimes(1);
1446+
1447+
yield* remote.input({ type: "releaseControl" });
1448+
page.openFileChooser();
1449+
expect(page.nativeFileChooser).toHaveBeenCalledTimes(2);
1450+
1451+
page.locator.mockReturnValue({
1452+
scrollIntoViewIfNeeded: async () => {},
1453+
boundingBox: async () => null,
1454+
click: async () => page.openFileChooser(),
1455+
} as never);
1456+
yield* broker.invoke({ scope, tabId, operation: "click", input: { locator: "input" } });
1457+
expect((yield* status()).fileChooser).toEqual({ multiple: false, accept: ".csv" });
1458+
yield* broker.invoke({ scope, tabId, operation: "upload", input: { paths: ["/tmp/a.csv"] } });
1459+
expect(page.setChooserFiles).toHaveBeenLastCalledWith(["/tmp/a.csv"], expect.anything());
1460+
expect((yield* status()).fileChooser).toBeNull();
1461+
page.openFileChooser();
1462+
expect(page.nativeFileChooser).toHaveBeenCalledTimes(3);
1463+
page.locator.mockReturnValue({
1464+
scrollIntoViewIfNeeded: async () => {
1465+
throw new Error("input disappeared");
1466+
},
1467+
} as never);
1468+
expect(
1469+
yield* Effect.flip(
1470+
broker.invoke<void>({ scope, tabId, operation: "click", input: { locator: "input" } }),
1471+
),
1472+
).toMatchObject({ _tag: "PreviewAutomationExecutionError" });
1473+
page.openFileChooser();
1474+
expect(page.nativeFileChooser).toHaveBeenCalledTimes(4);
1475+
expect(
1476+
(yield* Queue.clear(observer.output)).some((item) => item._tag === "fileChooser"),
1477+
).toBe(false);
1478+
}),
1479+
).pipe(Effect.provide(layer)),
1480+
);
1481+
13981482
it.live("a desktop page the desktop takes back reconnects instead of closing", () =>
13991483
Effect.scoped(
14001484
Effect.gen(function* () {

‎apps/server/src/preview/ServerBrowser.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,9 @@ interface ServerTab {
283283
readonly networkEntries: Array<PreviewAutomationNetworkEntry>;
284284
readonly actionTimeline: Array<PreviewAutomationActionEvent>;
285285
readonly control: SessionControl;
286+
agentOperating: boolean;
287+
fileChooserIntercepted: boolean;
288+
readonly onFileChooser: (chooser: FileChooser) => void;
286289

287290
readonly isolatedContext: boolean;
288291

@@ -500,7 +503,18 @@ const make = Effect.gen(function* () {
500503
}
501504
: null;
502505

506+
const updateFileChooserInterception = (tab: ServerTab) => {
507+
// Native desktop views use the OS picker. Only agents and streamed controllers
508+
// have an uploader that can answer an intercepted request.
509+
const intercept = !tab.desktop || tab.agentOperating || tab.control.controller !== null;
510+
if (intercept === tab.fileChooserIntercepted) return;
511+
tab.fileChooserIntercepted = intercept;
512+
if (intercept) tab.page.on("filechooser", tab.onFileChooser);
513+
else tab.page.off("filechooser", tab.onFileChooser);
514+
};
515+
503516
const broadcastControl = (tab: ServerTab) => {
517+
updateFileChooserInterception(tab);
504518
for (const viewer of tab.viewers)
505519
viewer.push({
506520
_tag: "control",
@@ -743,6 +757,9 @@ const make = Effect.gen(function* () {
743757
networkEntries: [],
744758
actionTimeline: [],
745759
control,
760+
agentOperating: false,
761+
fileChooserIntercepted: false,
762+
onFileChooser: (chooser) => void offerFileChooser(tab, chooser),
746763
isolatedContext,
747764
desktop: desktop === null ? null : { close: desktop.close },
748765
profileId: snapshot.profileId,
@@ -851,7 +868,7 @@ const make = Effect.gen(function* () {
851868
broadcastControl(tab);
852869
});
853870
page.on("download", (download) => void saveDownload(tab, download));
854-
page.on("filechooser", (chooser) => void offerFileChooser(tab, chooser));
871+
updateFileChooserInterception(tab);
855872
// Popups become tabs and keep `window.opener`, so sign-in popups can report back.
856873
page.on("popup", (popup) => void adoptPopup(tab, popup));
857874
// Playwright cannot reload a crashed page, so it must leave the tab list.
@@ -1850,9 +1867,13 @@ const make = Effect.gen(function* () {
18501867
"dialogPending",
18511868
);
18521869
const generation = tab.control.generation;
1870+
tab.agentOperating = !READ_OPERATIONS.has(request.operation);
1871+
updateFileChooserInterception(tab);
18531872
try {
18541873
return await executeTabOperation(tab, request);
18551874
} finally {
1875+
tab.agentOperating = false;
1876+
updateFileChooserInterception(tab);
18561877
if (generation !== tab.control.generation) ServerBrowserPage.invalidateRefs(tab.page);
18571878
}
18581879
});

0 commit comments

Comments
 (0)