diff --git a/ui/src/quick-pane/QuickPane.test.tsx b/ui/src/quick-pane/QuickPane.test.tsx index 0b3b3ca..02f1a9e 100644 --- a/ui/src/quick-pane/QuickPane.test.tsx +++ b/ui/src/quick-pane/QuickPane.test.tsx @@ -134,6 +134,41 @@ function bridgeMock( return { runtimeCount: () => runtimeCount }; } +function deferredCatalogBridge() { + const firstSnapshot = deferred(); + const staleModels: Model[] = [ + { provider: "provider", id: "stale/model", name: "Stale" }, + ]; + const freshModels: Model[] = [ + { provider: "provider", id: "fresh/model", name: "Fresh" }, + ]; + let runtimeCount = 0; + mocks.invoke.mockImplementation( + (command: string, payload?: { runtimeId?: string }) => { + if (command === "load_ui_state") return Promise.resolve(uiState); + if (command === "create_quick_runtime") { + runtimeCount += 1; + return Promise.resolve({ + runtime: { + agentId: `agent-${runtimeCount}`, + runtimeId: `runtime-${runtimeCount}`, + }, + }); + } + if (command === "get_session_runtime_snapshot") { + if (payload?.runtimeId === "runtime-1") return firstSnapshot.promise; + return Promise.resolve(runtimeSnapshot(freshModels)); + } + return Promise.resolve(undefined); + }, + ); + return { + firstSnapshot, + staleModels, + runtimeCount: () => runtimeCount, + }; +} + async function preparedTrigger() { return screen.findByRole("button", { name: /provider ·/i }); } @@ -578,6 +613,112 @@ describe("QuickPane fresh runtime catalog", () => { }); }); + it.each([ + { + action: "Close", + dismiss: async () => { + await userEvent.click( + screen.getByRole("button", { name: "Close Quick Pane" }), + ); + }, + }, + { + action: "Escape", + dismiss: async () => { + fireEvent.keyDown(window, { key: "Escape" }); + }, + }, + ])( + "discards deferred catalog preparation after $action dismissal", + async ({ dismiss }) => { + const tracker = deferredCatalogBridge(); + render(); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + "get_session_runtime_snapshot", + { runtimeId: "runtime-1" }, + ), + ); + + await dismiss(); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane"), + ); + await act(async () => + tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)), + ); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", { + runtimeId: "runtime-1", + }), + ); + expect( + screen.queryByRole("button", { name: /provider · Stale/i }), + ).toBeNull(); + + await act(async () => mocks.focusHandler?.({ payload: true })); + expect( + await screen.findByRole("button", { name: /provider · Fresh/i }), + ).toBeInTheDocument(); + expect(tracker.runtimeCount()).toBe(2); + await ask(); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("set_model", { + agentId: "agent-2", + provider: "provider", + modelId: "fresh/model", + }), + ); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "set_model", + expect.objectContaining({ agentId: "agent-1" }), + ); + }, + ); + + it("discards deferred catalog preparation after idle focus loss", async () => { + const tracker = deferredCatalogBridge(); + render(); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + "get_session_runtime_snapshot", + { runtimeId: "runtime-1" }, + ), + ); + + await act(async () => mocks.focusHandler?.({ payload: false })); + expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane"); + await act(async () => + tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)), + ); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", { + runtimeId: "runtime-1", + }), + ); + expect( + screen.queryByRole("button", { name: /provider · Stale/i }), + ).toBeNull(); + + await act(async () => mocks.focusHandler?.({ payload: true })); + expect( + await screen.findByRole("button", { name: /provider · Fresh/i }), + ).toBeInTheDocument(); + expect(tracker.runtimeCount()).toBe(2); + await ask(); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("set_model", { + agentId: "agent-2", + provider: "provider", + modelId: "fresh/model", + }), + ); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "set_model", + expect.objectContaining({ agentId: "agent-1" }), + ); + }); + it("retains a prior runtime when close fails and retries before replacement", async () => { let runtimeCount = 0; let closeAttempts = 0; diff --git a/ui/src/quick-pane/QuickPane.tsx b/ui/src/quick-pane/QuickPane.tsx index 71f29d6..d26540f 100644 --- a/ui/src/quick-pane/QuickPane.tsx +++ b/ui/src/quick-pane/QuickPane.tsx @@ -311,9 +311,17 @@ export default function QuickPane() { [clearRuntimeIfCurrent, closeRuntime], ); + const invalidateIdlePreparation = useCallback(() => { + if (requestActiveRef.current || submissionStartingRef.current) return; + preparationGenerationRef.current += 1; + preparationLockRef.current = false; + catalogReadyRef.current = false; + }, []); + const closeIdleRuntime = useCallback(async () => { const current = runtimeRef.current; - if (!current || requestActiveRef.current) return true; + if (!current || requestActiveRef.current || submissionStartingRef.current) + return true; try { await closeRuntime(current.runtimeId); clearRuntimeIfCurrent(current.runtimeId); @@ -326,8 +334,9 @@ export default function QuickPane() { }, [clearRuntimeIfCurrent, closeRuntime]); const dismissPane = useCallback(async () => { + invalidateIdlePreparation(); if (await closeIdleRuntime()) await invoke("dismiss_quick_pane"); - }, [closeIdleRuntime]); + }, [closeIdleRuntime, invalidateIdlePreparation]); useEffect(() => { let active = true; @@ -420,7 +429,8 @@ export default function QuickPane() { focusInput(); if (!requestActiveRef.current && !submissionStartingRef.current) void prepareRuntime(); - } else if (!submissionStartingRef.current) { + } else if (!requestActiveRef.current && !submissionStartingRef.current) { + invalidateIdlePreparation(); void closeIdleRuntime(); } }); @@ -429,7 +439,12 @@ export default function QuickPane() { void settingsListener.then((unlisten) => unlisten()); void focusListener.then((unlisten) => unlisten()); }; - }, [closeIdleRuntime, dismissPane, prepareRuntime]); + }, [ + closeIdleRuntime, + dismissPane, + invalidateIdlePreparation, + prepareRuntime, + ]); useEffect(() => { if (!runtime || !requestActive) return;