From 9c4ca1e75432b4847793a11d31fc180a9258b5fd Mon Sep 17 00:00:00 2001 From: Steliyan Slavov Date: Mon, 19 Oct 2026 17:17:11 +0300 Subject: [PATCH 1/5] fix(server): a file picker closes when its page is replaced A file picker the controller had not answered stayed open after the page navigated to a new document, so the panel kept asking for a file the new page never requested. Probe the input element at DCL and close the picker only when its document was replaced; keep it open while the same document is still active. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 179 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 21 ++ 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 1e9ec719c4759..d69c2392f2a92 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -107,6 +107,18 @@ function makeSession(frame?: ReturnType) { }; } +function fileChooserElement(accept: string) { + return { + getAttribute: async () => accept, + evaluate: (probe: (element: { ownerDocument: unknown }) => boolean) => + Promise.resolve( + probe({ + ownerDocument: (globalThis as typeof globalThis & { document?: unknown }).document, + }), + ), + }; +} + function makeContext(onClose?: (context: BrowserContext) => void) { const events = new NodeEvents.EventEmitter(); const nativeFileChooser = vi.fn(); @@ -127,7 +139,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { if ( !events.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), + element: () => fileChooserElement(".csv"), setFiles: setChooserFiles, }) ) { @@ -1385,7 +1397,7 @@ it.live("a page's file picker goes to the controller and takes its uploaded file const setFiles = vi.fn(async (_files: unknown) => {}); page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), + element: () => fileChooserElement(".csv"), setFiles, }); let offered = yield* Queue.take(viewer.output); @@ -1410,6 +1422,163 @@ it.live("a page's file picker goes to the controller and takes its uploaded file ).pipe(Effect.provide(layer)), ); +it.live("a file picker from a detached input stays open across its own DCL", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const pageDocument = {}; + vi.stubGlobal("document", pageDocument); + yield* Effect.addFinalizer(() => Effect.sync(() => vi.unstubAllGlobals())); + const detachedInput = { isConnected: false, ownerDocument: pageDocument }; + const dclProbe = Promise.withResolvers(); + const dclProbeStarted = Promise.withResolvers(); + const dclProbeFinished = Promise.withResolvers(); + let probeCount = 0; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: (probe: (element: typeof detachedInput) => boolean) => { + probeCount++; + if (probeCount === 1) return Promise.resolve(probe(detachedInput)); + dclProbeStarted.resolve(); + return dclProbe.promise.then((current) => { + const result = probe(detachedInput); + dclProbeFinished.resolve(); + return current && result; + }); + }, + }), + setFiles: vi.fn(async (_files: unknown) => {}), + }); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); + + page.emit("domcontentloaded"); + yield* Effect.promise(() => dclProbeStarted.promise); + dclProbe.resolve(true); + yield* Effect.promise(() => dclProbeFinished.promise); + yield* viewer.input({ type: "releaseControl" }); + let closedAfterDcl = false; + let control = yield* Queue.take(viewer.output); + while (control._tag !== "control" || control.controller === "you") { + if (control._tag === "fileChooserClosed") closedAfterDcl = true; + control = yield* Queue.take(viewer.output); + } + expect(closedAfterDcl).toBe(false); + yield* viewer.input({ type: "takeControl" }); + let restoredControl = yield* Queue.take(viewer.output); + while (restoredControl._tag !== "control" || restoredControl.controller !== "you") { + restoredControl = yield* Queue.take(viewer.output); + } + + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: offered.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a file picker closes when its page is replaced by a new document", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const setFiles = vi.fn(async (_files: unknown) => {}); + const probeStarted = Promise.withResolvers(); + const probeSettled = Promise.withResolvers(); + let probeCount = 0; + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: () => { + probeCount++; + if (probeCount === 1) return Promise.resolve(true); + probeStarted.resolve(); + return Promise.reject(new Error("Execution context was destroyed")).finally(() => + probeSettled.resolve(), + ); + }, + }), + setFiles, + }); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); + + page.emit("domcontentloaded"); + yield* Effect.promise(() => probeStarted.promise); + yield* Effect.promise(() => probeSettled.promise); + let closed = yield* Queue.take(viewer.output); + while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(viewer.output); + expect(closed.id).toBe(offered.id); + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: offered.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(false); + expect(setFiles).not.toHaveBeenCalled(); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a file picker is not offered after its document changes while reading accept", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const accept = Promise.withResolvers(); + const acceptRead = Promise.withResolvers(); + const current = Promise.withResolvers(); + const probeStarted = Promise.withResolvers(); + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: () => { + acceptRead.resolve(); + return accept.promise; + }, + evaluate: () => { + probeStarted.resolve(); + return current.promise; + }, + }), + setFiles: vi.fn(async (_files: unknown) => {}), + }); + + yield* Effect.promise(() => acceptRead.promise); + page.emit("domcontentloaded"); + accept.resolve(""); + yield* Effect.promise(() => accept.promise); + yield* Effect.promise(() => probeStarted.promise); + current.resolve(false); + yield* Effect.promise(() => current.promise); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.fileChooser).toBeNull(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("only the current controller can answer a file picker", () => Effect.scoped( Effect.gen(function* () { @@ -1419,7 +1588,7 @@ it.live("only the current controller can answer a file picker", () => const setFiles = vi.fn(async (_files: unknown) => {}); contexts[0]!.page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => "" }), + element: () => fileChooserElement(""), setFiles, }); let offered = yield* Queue.take(first.output); @@ -1449,7 +1618,7 @@ it.live("a file picker replaces a stalled viewer backlog instead of being droppe const accept = Promise.withResolvers(); page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: () => accept.promise }), + element: () => ({ ...fileChooserElement(""), getAttribute: () => accept.promise }), setFiles: async () => {}, }); yield* Queue.clear(viewer.output); @@ -1562,7 +1731,7 @@ it.live("an agent answers the page's file picker or sets files on a file input", const setFiles = vi.fn(async (_files: unknown) => {}); page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), + element: () => fileChooserElement(".csv"), setFiles, }); const status = broker.invoke({ diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 91336458c09c5..824e179d751f9 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -801,6 +801,15 @@ const make = Effect.gen(function* () { tab.loading = false; void reportLoaded(tab); }); + // A new document fires DCL; the probe distinguishes it from the chooser's own document. + page.on("domcontentloaded", () => { + const open = tab.fileChooser; + if (open) { + void isFileChooserCurrent(open.chooser).then((current) => { + if (!current && tab.fileChooser === open) closeFileChooser(tab); + }); + } + }); page.on("framenavigated", (frame) => { // Same-document navigations (SPA routes) fire no load event. if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); @@ -998,6 +1007,17 @@ const make = Effect.gen(function* () { } : null; + // Not isConnected: upload helpers often click an input never added to the page. The handle's + // context is destroyed with its document, so a replaced document rejects. + const isFileChooserCurrent = (chooser: FileChooser) => + chooser + .element() + .evaluate( + (element) => + element.ownerDocument === (globalThis as unknown as { document: unknown }).document, + ) + .catch(() => false); + const offerFileChooser = async (tab: ServerTab, chooser: FileChooser) => { const previous = tab.fileChooser; const accept = @@ -1005,6 +1025,7 @@ const make = Effect.gen(function* () { .element() .getAttribute("accept") .catch(() => null)) ?? ""; + if (!(await isFileChooserCurrent(chooser))) return; if (previous) closeFileChooser(tab); tab.fileChooser = { id: NodeCrypto.randomUUID(), From 0e2894c602c9461b95535c498809f4c8e26f2d36 Mon Sep 17 00:00:00 2001 From: Steliyan Slavov Date: Tue, 20 Oct 2026 04:28:10 +0300 Subject: [PATCH 2/5] fix(server): match pending file chooser by chooser handle A control change during the document probe re-keys the chooser record, so the probe compared a stale record and left a replaced page's picker open. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 48 +++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index d69c2392f2a92..0b1534aa3e93a 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1535,6 +1535,54 @@ it.live("a file picker closes when its page is replaced by a new document", () = ).pipe(Effect.provide(layer)), ); +it.live("a file picker closes when its page is replaced while control changes hands", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const first = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* first.input({ type: "takeControl" }); + const current = Promise.withResolvers(); + const probeStarted = Promise.withResolvers(); + let probeCount = 0; + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: () => { + probeCount++; + if (probeCount === 1) return Promise.resolve(true); + probeStarted.resolve(); + return current.promise; + }, + }), + setFiles: vi.fn(async (_files: unknown) => {}), + }); + let offered = yield* Queue.take(first.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(first.output); + + page.emit("domcontentloaded"); + yield* Effect.promise(() => probeStarted.promise); + const second = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* first.input({ type: "releaseControl" }); + yield* second.input({ type: "takeControl" }); + let reoffered = yield* Queue.take(second.output); + while (reoffered._tag !== "fileChooser") reoffered = yield* Queue.take(second.output); + current.resolve(false); + let closed = yield* Queue.take(second.output); + while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(second.output); + expect(closed.id).toBe(reoffered.id); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.fileChooser).toBeNull(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a file picker is not offered after its document changes while reading accept", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 824e179d751f9..ab8b0f4a8a855 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -806,7 +806,7 @@ const make = Effect.gen(function* () { const open = tab.fileChooser; if (open) { void isFileChooserCurrent(open.chooser).then((current) => { - if (!current && tab.fileChooser === open) closeFileChooser(tab); + if (!current && tab.fileChooser?.chooser === open.chooser) closeFileChooser(tab); }); } }); From b353d69ec6713ec002a1b6e01b9dd6152cb47f08 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:34:51 +0000 Subject: [PATCH 3/5] fix(server): dismiss stale file pickers when navigation commits --- apps/server/src/preview/ServerBrowser.test.ts | 124 +++++++++--------- apps/server/src/preview/ServerBrowser.ts | 5 +- 2 files changed, 64 insertions(+), 65 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 0cd248c50e0e5..994c130099681 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1106,7 +1106,7 @@ it.live("a page's file picker goes to the controller and takes its uploaded file ).pipe(Effect.provide(layer)), ); -it.live("a file picker from a detached input stays open across its own DCL", () => +it.live("a detached file picker stays open across same-document navigation", () => Effect.scoped( Effect.gen(function* () { const { browser, tabId } = yield* ready; @@ -1115,9 +1115,9 @@ it.live("a file picker from a detached input stays open across its own DCL", () vi.stubGlobal("document", pageDocument); yield* Effect.addFinalizer(() => Effect.sync(() => vi.unstubAllGlobals())); const detachedInput = { isConnected: false, ownerDocument: pageDocument }; - const dclProbe = Promise.withResolvers(); - const dclProbeStarted = Promise.withResolvers(); - const dclProbeFinished = Promise.withResolvers(); + const navigationProbe = Promise.withResolvers(); + const navigationProbeStarted = Promise.withResolvers(); + const navigationProbeFinished = Promise.withResolvers(); let probeCount = 0; const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); yield* viewer.input({ type: "takeControl" }); @@ -1128,10 +1128,10 @@ it.live("a file picker from a detached input stays open across its own DCL", () evaluate: (probe: (element: typeof detachedInput) => boolean) => { probeCount++; if (probeCount === 1) return Promise.resolve(probe(detachedInput)); - dclProbeStarted.resolve(); - return dclProbe.promise.then((current) => { + navigationProbeStarted.resolve(); + return navigationProbe.promise.then((current) => { const result = probe(detachedInput); - dclProbeFinished.resolve(); + navigationProbeFinished.resolve(); return current && result; }); }, @@ -1141,18 +1141,18 @@ it.live("a file picker from a detached input stays open across its own DCL", () let offered = yield* Queue.take(viewer.output); while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); - page.emit("domcontentloaded"); - yield* Effect.promise(() => dclProbeStarted.promise); - dclProbe.resolve(true); - yield* Effect.promise(() => dclProbeFinished.promise); + page.emit("framenavigated", page); + yield* Effect.promise(() => navigationProbeStarted.promise); + navigationProbe.resolve(true); + yield* Effect.promise(() => navigationProbeFinished.promise); yield* viewer.input({ type: "releaseControl" }); - let closedAfterDcl = false; + let closedAfterNavigation = false; let control = yield* Queue.take(viewer.output); while (control._tag !== "control" || control.controller === "you") { - if (control._tag === "fileChooserClosed") closedAfterDcl = true; + if (control._tag === "fileChooserClosed") closedAfterNavigation = true; control = yield* Queue.take(viewer.output); } - expect(closedAfterDcl).toBe(false); + expect(closedAfterNavigation).toBe(false); yield* viewer.input({ type: "takeControl" }); let restoredControl = yield* Queue.take(viewer.output); while (restoredControl._tag !== "control" || restoredControl.controller !== "you") { @@ -1171,52 +1171,54 @@ it.live("a file picker from a detached input stays open across its own DCL", () ).pipe(Effect.provide(layer)), ); -it.live("a file picker closes when its page is replaced by a new document", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, tabId } = yield* ready; - const page = contexts[0]!.page; - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* viewer.input({ type: "takeControl" }); - const setFiles = vi.fn(async (_files: unknown) => {}); - const probeStarted = Promise.withResolvers(); - const probeSettled = Promise.withResolvers(); - let probeCount = 0; - page.emit("filechooser", { - isMultiple: () => false, - element: () => ({ - getAttribute: async () => "", - evaluate: () => { - probeCount++; - if (probeCount === 1) return Promise.resolve(true); - probeStarted.resolve(); - return Promise.reject(new Error("Execution context was destroyed")).finally(() => - probeSettled.resolve(), - ); - }, - }), - setFiles, - }); - let offered = yield* Queue.take(viewer.output); - while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); +it.live.each(["main", "iframe"])( + "a file picker closes at %s document commit before DOMContentLoaded", + (frame) => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const setFiles = vi.fn(async (_files: unknown) => {}); + const probeStarted = Promise.withResolvers(); + const probeSettled = Promise.withResolvers(); + let probeCount = 0; + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: () => { + probeCount++; + if (probeCount === 1) return Promise.resolve(true); + probeStarted.resolve(); + return Promise.reject(new Error("Execution context was destroyed")).finally(() => + probeSettled.resolve(), + ); + }, + }), + setFiles, + }); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); - page.emit("domcontentloaded"); - yield* Effect.promise(() => probeStarted.promise); - yield* Effect.promise(() => probeSettled.promise); - let closed = yield* Queue.take(viewer.output); - while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(viewer.output); - expect(closed.id).toBe(offered.id); - expect( - yield* browser.answerFileChooser({ - threadId: scope.thread.threadId, - tabId, - chooserId: offered.id, - files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], - }), - ).toBe(false); - expect(setFiles).not.toHaveBeenCalled(); - }), - ).pipe(Effect.provide(layer)), + page.emit("framenavigated", frame === "main" ? page : makeFrame()); + yield* Effect.promise(() => probeStarted.promise); + yield* Effect.promise(() => probeSettled.promise); + let closed = yield* Queue.take(viewer.output); + while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(viewer.output); + expect(closed.id).toBe(offered.id); + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: offered.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(false); + expect(setFiles).not.toHaveBeenCalled(); + }), + ).pipe(Effect.provide(layer)), ); it.live("a file picker closes when its page is replaced while control changes hands", () => @@ -1245,7 +1247,7 @@ it.live("a file picker closes when its page is replaced while control changes ha let offered = yield* Queue.take(first.output); while (offered._tag !== "fileChooser") offered = yield* Queue.take(first.output); - page.emit("domcontentloaded"); + page.emit("framenavigated", page); yield* Effect.promise(() => probeStarted.promise); const second = yield* browser.attachViewer(viewerInput(tabId, true)); yield* first.input({ type: "releaseControl" }); @@ -1294,7 +1296,7 @@ it.live("a file picker is not offered after its document changes while reading a }); yield* Effect.promise(() => acceptRead.promise); - page.emit("domcontentloaded"); + page.emit("framenavigated", page); accept.resolve(""); yield* Effect.promise(() => accept.promise); yield* Effect.promise(() => probeStarted.promise); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 99fc3bb095bc8..d6acd4fc0ed6e 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -799,16 +799,13 @@ const make = Effect.gen(function* () { tab.loading = false; void reportLoaded(tab); }); - // A new document fires DCL; the probe distinguishes it from the chooser's own document. - page.on("domcontentloaded", () => { + page.on("framenavigated", (frame) => { const open = tab.fileChooser; if (open) { void isFileChooserCurrent(open.chooser).then((current) => { if (!current && tab.fileChooser?.chooser === open.chooser) closeFileChooser(tab); }); } - }); - page.on("framenavigated", (frame) => { // Same-document navigations (SPA routes) fire no load event. if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); if (!desktop) void recordFrameCopies(tab, frame); From c1f1d7f3cabf7faf2412d84a8606f502cfabc532 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:52:41 +0000 Subject: [PATCH 4/5] fix(server): serialize overlapping file picker offers --- apps/server/src/preview/ServerBrowser.test.ts | 40 +++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 7 +++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 994c130099681..e37be1acff700 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1106,6 +1106,46 @@ it.live("a page's file picker goes to the controller and takes its uploaded file ).pipe(Effect.provide(layer)), ); +it.live("overlapping file pickers keep the newer offer answerable", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const accept = Promise.withResolvers(); + const firstFiles = vi.fn(async (_files: unknown) => {}); + const secondFiles = vi.fn(async (_files: unknown) => {}); + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ ...fileChooserElement(""), getAttribute: () => accept.promise }), + setFiles: firstFiles, + }); + page.emit("filechooser", { + isMultiple: () => false, + element: () => fileChooserElement(".csv"), + setFiles: secondFiles, + }); + accept.resolve(".txt"); + let first = yield* Queue.take(viewer.output); + while (first._tag !== "fileChooser") first = yield* Queue.take(viewer.output); + let second = yield* Queue.take(viewer.output); + while (second._tag !== "fileChooser") second = yield* Queue.take(viewer.output); + expect(second.accept).toBe(".csv"); + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: second.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(true); + expect(firstFiles).not.toHaveBeenCalled(); + expect(secondFiles).toHaveBeenCalledOnce(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a detached file picker stays open across same-document navigation", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index d6acd4fc0ed6e..08719c63a54bd 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -741,6 +741,7 @@ const make = Effect.gen(function* () { const control = new SessionControl(snapshot.automationOwner ?? null, () => ServerBrowserPage.invalidateRefs(page), ); + let pendingFileChooserOffer = Promise.resolve(); const tab: ServerTab = { threadId: ThreadId.make(snapshot.threadId), tabId: snapshot.tabId, @@ -756,7 +757,11 @@ const make = Effect.gen(function* () { control, agentOperating: false, fileChooserIntercepted: false, - onFileChooser: (chooser) => void offerFileChooser(tab, chooser), + onFileChooser: (chooser) => { + pendingFileChooserOffer = pendingFileChooserOffer + .then(() => offerFileChooser(tab, chooser)) + .catch(constVoid); + }, isolatedContext, desktop: desktop === null ? null : { close: desktop.close }, profileId: snapshot.profileId, From 41f1964345a4d9853ae01cfceab28c2900a0df46 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:18:21 +0000 Subject: [PATCH 5/5] test(server): complete the desktop file chooser fixture --- apps/server/src/preview/ServerBrowser.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 8a60748adbca9..bf24dd09eb781 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1547,7 +1547,7 @@ it.effect("a stalled desktop viewer still gets its size, control and reconnect", yield* viewer.input({ type: "takeControl" }); context.page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), + element: () => fileChooserElement(".csv"), setFiles: vi.fn(async () => {}), }); let chooser = yield* Queue.take(viewer.output);