Browse Source

Keep project directory browse usable under intercepted pickers

Browser directory picking can be intercepted by embedded automation environments, which raises before the user can choose a folder. The project setup browse flow now treats that specific interception as a signal to use the existing server browse-directory fallback, while preserving normal browser picker behavior and cancellation semantics.

Constraint: In-app browser automation may install Page.setInterceptFileChooserDialog and break showDirectoryPicker
Constraint: Browser File System Access selection should still be preferred when it works
Rejected: Always fall back after any browser picker error | permission and unsupported errors should remain visible instead of silently choosing a server path
Confidence: high
Scope-risk: narrow
Directive: Do not broaden fallback errors without checking browser local-project semantics
Tested: npm run test:run -- src/components/__tests__/ProjectSetupModal.test.tsx --testNamePattern "fall back to browse-directory API"
Tested: npm run test:run -- src/components/__tests__/ProjectSetupModal.test.tsx --testNamePattern "Browse Button"
Tested: npm run test:run -- src/components/__tests__/ProjectSetupModal.test.tsx --testNamePattern "fall back to browse-directory API|should call browse-directory API|should keep selected parent path|should handle cancelled browse dialog"
Tested: npm run build
Not-tested: npm run lint | existing Next 16 next lint script resolves to nonexistent /lint directory
Not-tested: Full ProjectSetupModal test file | existing Provider tab assertions expect API key inputs hidden by current Popi mode
feature/gateway
jiajia 2 months ago
parent
commit
3f9030a564
  1. 31
      src/components/ProjectSetupModal.tsx
  2. 45
      src/components/__tests__/ProjectSetupModal.test.tsx

31
src/components/ProjectSetupModal.tsx

@ -86,6 +86,20 @@ function getDefaultModelForLLMProvider(provider: LLMProvider): LLMModelType {
return LLM_MODELS[provider][0].value;
}
function getBrowseErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isDirectoryPickerCancel(error: unknown): boolean {
if (error instanceof DOMException && error.name === "AbortError") return true;
const message = getBrowseErrorMessage(error);
return message.includes("User canceled") || message.includes("-128");
}
function shouldFallbackToServerDirectoryPicker(error: unknown): boolean {
return getBrowseErrorMessage(error).includes("Page.setInterceptFileChooserDialog");
}
interface ProjectSetupModalProps {
isOpen: boolean;
onClose: () => void;
@ -258,11 +272,20 @@ export function ProjectSetupModal({
try {
if (isBrowserDirectoryPickerSupported()) {
const browserPath = await pickBrowserWorkflowDirectory();
if (browserPath) {
setDirectoryPath(browserPath);
try {
const browserPath = await pickBrowserWorkflowDirectory();
if (browserPath) {
setDirectoryPath(browserPath);
}
return;
} catch (browserPickerError) {
if (isDirectoryPickerCancel(browserPickerError)) {
return;
}
if (!shouldFallbackToServerDirectoryPicker(browserPickerError)) {
throw browserPickerError;
}
}
return;
}
const response = await fetch("/api/browse-directory");

45
src/components/__tests__/ProjectSetupModal.test.tsx

@ -109,6 +109,7 @@ describe("ProjectSetupModal", () => {
afterEach(() => {
vi.restoreAllMocks();
delete window.showDirectoryPicker;
});
describe("Visibility", () => {
@ -692,6 +693,50 @@ describe("ProjectSetupModal", () => {
});
describe("Browse Button", () => {
it("should fall back to browse-directory API when browser directory picker is intercepted", async () => {
Object.defineProperty(window, "showDirectoryPicker", {
value: vi.fn().mockRejectedValue(
new Error("Failed to execute 'showDirectoryPicker' on 'Window': Intercepted by Page.setInterceptFileChooserDialog().")
),
configurable: true,
});
mockFetch.mockImplementation((url: string) => {
if (url === "/api/env-status") {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }),
});
}
if (url === "/api/browse-directory") {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true, path: "/selected/path" }),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
});
render(
<ProjectSetupModal
isOpen={true}
onClose={vi.fn()}
onSave={vi.fn()}
mode="new"
/>
);
fireEvent.click(screen.getByText("Browse"));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/browse-directory");
const directoryInput = screen.getByDisplayValue("/selected/path") as HTMLInputElement;
expect(directoryInput.value).toBe("/selected/path");
});
expect(screen.queryByText(/Intercepted by Page\.setInterceptFileChooserDialog/)).not.toBeInTheDocument();
});
it("should call browse-directory API when Browse is clicked", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/env-status") {

Loading…
Cancel
Save