diff --git a/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md b/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md
new file mode 100644
index 0000000000..18328e15e0
--- /dev/null
+++ b/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Keep dismissed OAuth re-login banners hidden until successful re-login (fixes Copilot flicker).
+category: fix
+dev: OAuthReloginBanner no longer prunes fusion:oauth-relogin-dismissed when a provider leaves the expired set after silent refresh; OAUTH_RELOGIN_SUCCESS_EVENT clears that provider's dismissal to re-arm.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index dc14110582..5259de8a23 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -897,6 +897,8 @@ Branch names are dynamic from merge/audit payloads; the banner is not hardcoded
The global OAuth re-login banner clears a provider row immediately after that provider successfully re-authenticates (from Settings → Authentication or Model Onboarding), instead of waiting for the next `GET /auth/status` poll interval.
+Dismissing the banner suppresses each currently shown provider until that provider successfully re-authenticates. A silent refresh or temporary non-expired `/auth/status` response does not clear the dismissal, preventing short-lived GitHub Copilot tokens from repeatedly re-showing a manually dismissed banner; a successful re-login re-arms that provider for a future true expiry.
+
For OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the provider without manual re-login and without waiting for a separate model request.
If the OAuth credential has no refresh token or the refresh request fails/leaves the credential expired, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. On Fusion desktop, OAuth login URLs open in the operating system browser rather than an in-app Electron child window; the Settings/Onboarding UI keeps polling until the provider authenticates or the login truly stops.
diff --git a/packages/dashboard/app/components/OAuthReloginBanner.tsx b/packages/dashboard/app/components/OAuthReloginBanner.tsx
index 7354ea63a3..fae9d7327c 100644
--- a/packages/dashboard/app/components/OAuthReloginBanner.tsx
+++ b/packages/dashboard/app/components/OAuthReloginBanner.tsx
@@ -69,17 +69,6 @@ export function OAuthReloginBanner({
const nextExpiredProviders = getVisibleExpiredOAuthProvidersForGlobalBanner(providers);
setExpiredProviders(nextExpiredProviders);
- setDismissedProviderIds((currentDismissed) => {
- const expiredProviderIds = new Set(nextExpiredProviders.map((provider) => provider.id));
- const filteredDismissed = new Set(
- Array.from(currentDismissed).filter((providerId) => expiredProviderIds.has(providerId)),
- );
- if (filteredDismissed.size === currentDismissed.size) {
- return currentDismissed;
- }
- persistDismissedProviderIds(filteredDismissed);
- return filteredDismissed;
- });
} catch {
// Non-blocking banner; ignore transient status fetch failures.
}
@@ -97,8 +86,23 @@ export function OAuthReloginBanner({
useEffect(() => {
const handleOAuthReloginSuccess = (event: Event) => {
const { detail } = event as CustomEvent<{ providerId?: string }>;
- if (detail?.providerId) {
- setExpiredProviders((current) => current.filter((provider) => provider.id !== detail.providerId));
+ const providerId = detail?.providerId;
+ if (providerId) {
+ setExpiredProviders((current) => current.filter((provider) => provider.id !== providerId));
+ setDismissedProviderIds((currentDismissed) => {
+ if (!currentDismissed.has(providerId)) {
+ return currentDismissed;
+ }
+
+ /*
+ FNXC:ProviderAuth 2026-07-20-12:00:
+ FN-8446 — A manual dismissal must survive an expired→refreshed→expired flicker from short-lived OAuth tokens such as GitHub Copilot. Re-arm only this provider after its successful re-login event; polling a healthy status must never prune its browser preference.
+ */
+ const nextDismissed = new Set(currentDismissed);
+ nextDismissed.delete(providerId);
+ persistDismissedProviderIds(nextDismissed);
+ return nextDismissed;
+ });
}
void refreshAuthStatus();
};
diff --git a/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx b/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx
index da9a8cf24c..23044166c4 100644
--- a/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx
+++ b/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx
@@ -129,39 +129,130 @@ describe("OAuthReloginBanner", () => {
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
});
- it("keeps dismissed state scoped to currently expired provider ids", async () => {
+ it("keeps a dismissed GitHub Copilot banner hidden across refresh and re-expiry", async () => {
+ mockFetchAuthStatus
+ .mockResolvedValueOnce({
+ providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }],
+ ghCli: { available: false, authenticated: false },
+ })
+ .mockResolvedValueOnce({
+ providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: true, expired: false }],
+ ghCli: { available: false, authenticated: false },
+ })
+ .mockResolvedValueOnce({
+ providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }],
+ ghCli: { available: false, authenticated: false },
+ });
+
+ const firstRender = render();
+ expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot");
+ fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner"));
+ await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
+ expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot");
+
+ firstRender.unmount();
+ const refreshedRender = render();
+ await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(2));
+ expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot");
+
+ refreshedRender.unmount();
+ render();
+ await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(3));
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("keeps multiple dismissed providers suppressed while showing newly expired providers", async () => {
mockFetchAuthStatus
.mockResolvedValueOnce({
providers: [
- { id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true },
+ { id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true },
+ { id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true },
],
ghCli: { available: false, authenticated: false },
})
- .mockResolvedValueOnce({
- providers: [],
- ghCli: { available: false, authenticated: false },
- })
+ .mockResolvedValueOnce({ providers: [], ghCli: { available: false, authenticated: false } })
.mockResolvedValueOnce({
providers: [
{ id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true },
+ { id: "google-gemini", name: "Google Gemini", type: "oauth", authenticated: false, expired: true },
],
ghCli: { available: false, authenticated: false },
});
- const { unmount } = render();
- expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription");
-
+ const firstRender = render();
+ expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot, OpenAI Codex");
fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner"));
- await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
+ firstRender.unmount();
+
+ const clearedRender = render();
+ await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(2));
+ clearedRender.unmount();
- unmount();
render();
+ expect(await screen.findByRole("status")).toHaveTextContent("Google Gemini");
+ expect(screen.getByRole("status")).not.toHaveTextContent("OpenAI Codex");
+ expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot");
+ expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("openai-codex");
+ });
+
+ it("re-arms a dismissed provider after its successful OAuth re-login", async () => {
+ mockFetchAuthStatus
+ .mockResolvedValueOnce({
+ providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }],
+ ghCli: { available: false, authenticated: false },
+ })
+ .mockResolvedValueOnce({
+ providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: true, expired: false }],
+ ghCli: { available: false, authenticated: false },
+ })
+ .mockResolvedValueOnce({
+ providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }],
+ ghCli: { available: false, authenticated: false },
+ });
+
+ const firstRender = render();
+ expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot");
+ fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner"));
+
await act(async () => {
+ window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "github-copilot" } }));
await flushPromises();
});
- unmount();
+ expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).not.toContain("github-copilot");
+ firstRender.unmount();
render();
- expect(await screen.findByRole("status")).toHaveTextContent("OpenAI Codex");
+ expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot");
+ });
+
+ it("does not clear dismissed providers for an OAuth success event without a provider id", async () => {
+ mockFetchAuthStatus
+ .mockResolvedValueOnce({
+ providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }],
+ ghCli: { available: false, authenticated: false },
+ })
+ .mockResolvedValueOnce({
+ providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }],
+ ghCli: { available: false, authenticated: false },
+ })
+ .mockResolvedValueOnce({
+ providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }],
+ ghCli: { available: false, authenticated: false },
+ });
+
+ const firstRender = render();
+ expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot");
+ fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner"));
+
+ await act(async () => {
+ window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT));
+ await flushPromises();
+ });
+ expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot");
+
+ firstRender.unmount();
+ render();
+ await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(3));
+ expect(screen.queryByRole("status")).toBeNull();
});
});