From 5c3fa86e6f15de59adebc7ca8693cb75432478a7 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 15:34:00 -0700 Subject: [PATCH] Fix queued external file dispatch --- .../AppFileExternalNavigationHost.test.tsx | 86 +++++++++++++++---- .../plugin/AppFileExternalNavigationHost.tsx | 17 +++- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx index fbd27cbe52..6f120d58fb 100644 --- a/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx @@ -12,11 +12,12 @@ import { useAppNavigationHost } from "@/lib/app-navigation-host"; import { AppFileExternalNavigationHost } from "./AppFileExternalNavigationHost"; const openPreferred = vi.hoisted(() => vi.fn()); +const recordAccepted = vi.fn(); vi.mock("@/hooks/useResolvedLiveFileTarget", () => ({ - useResolvedLiveFileTarget: () => ({ + useResolvedLiveFileTarget: (target: { path: string }) => ({ status: "available", - absolutePath: "/workspace/src/example.ts", + absolutePath: `/workspace/${target.path}`, hostId: "host_1", openContext: { kind: "local" }, }), @@ -32,21 +33,47 @@ vi.mock("@/hooks/useLocalOpenTargets", () => ({ function Probe() { const navigation = useAppNavigationHost(); return ( - + <> + + + ); } @@ -54,6 +81,7 @@ afterEach(() => { cleanup(); openPreferred.mockReset(); openPreferred.mockResolvedValue(true); + recordAccepted.mockReset(); }); describe("AppFileExternalNavigationHost", () => { @@ -74,4 +102,28 @@ describe("AppFileExternalNavigationHost", () => { { timeout: 5_000 }, ); }); + + it("dispatches queued intents once each in FIFO order", async () => { + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Open two external" })); + + expect(recordAccepted).toHaveBeenCalledWith(true, true); + await waitFor(() => expect(openPreferred).toHaveBeenCalledTimes(2), { + timeout: 5_000, + }); + expect(openPreferred).toHaveBeenNthCalledWith(1, { + columnNumber: 2, + lineNumber: 10, + path: "/workspace/src/first.ts", + }); + expect(openPreferred).toHaveBeenNthCalledWith(2, { + columnNumber: 4, + lineNumber: 20, + path: "/workspace/src/second.ts", + }); + }); }); diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx index bbed6792b9..390ede239d 100644 --- a/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx @@ -19,15 +19,21 @@ const LazyAppFileExternalNavigationDispatcher = lazy(() => ), ); +interface ExternalFileIntentRequest { + id: number; + intent: ExperimentalFileOpenOptions; +} + /** App-wide preferred-external file dispatcher; discovery starts on activation. */ export function AppFileExternalNavigationHost({ children, }: { children: ReactNode; }) { - const [queue, setQueue] = useState([]); + const [queue, setQueue] = useState([]); const queueRef = useRef(queue); - const replaceQueue = useCallback((next: ExperimentalFileOpenOptions[]) => { + const nextRequestIdRef = useRef(0); + const replaceQueue = useCallback((next: ExternalFileIntentRequest[]) => { queueRef.current = next; setQueue(next); }, []); @@ -38,7 +44,9 @@ export function AppFileExternalNavigationHost({ } // Public SDK callers are parsed by useBbNavigate before capabilities are // invoked; this host only queues that already-normalized internal value. - replaceQueue([...queueRef.current, intent]); + const request = { id: nextRequestIdRef.current, intent }; + nextRequestIdRef.current += 1; + replaceQueue([...queueRef.current, request]); return true; }, [replaceQueue], @@ -58,7 +66,8 @@ export function AppFileExternalNavigationHost({ {current === null ? null : (