diff --git a/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md b/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md
index 18328e15e0..03bb86cb68 100644
--- a/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md
+++ b/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md
@@ -2,6 +2,6 @@
"@runfusion/fusion": patch
---
-summary: Keep dismissed OAuth re-login banners hidden until successful re-login (fixes Copilot flicker).
+summary: Keep dismissed GitHub Copilot re-login banners hidden permanently.
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.
+dev: OAuthReloginBanner preserves the github-copilot dismissal across polling and successful-login events; other providers still re-arm after successful re-login.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 90e052af6b..f97dfa8d52 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -897,7 +897,7 @@ 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.
+Dismissing the banner suppresses each currently shown provider. GitHub Copilot stays dismissed permanently for that browser profile because its short-lived sessions can repeatedly emit successful-login events; clearing browser storage restores the banner. Other providers re-arm after a successful re-login. A silent refresh or temporary non-expired `/auth/status` response never clears a dismissal.
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.
diff --git a/packages/dashboard/app/components/OAuthReloginBanner.tsx b/packages/dashboard/app/components/OAuthReloginBanner.tsx
index fae9d7327c..26358df712 100644
--- a/packages/dashboard/app/components/OAuthReloginBanner.tsx
+++ b/packages/dashboard/app/components/OAuthReloginBanner.tsx
@@ -7,6 +7,7 @@ import "./OAuthReloginBanner.css";
const DISMISS_STORAGE_KEY = "fusion:oauth-relogin-dismissed";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
+const GITHUB_COPILOT_PROVIDER_ID = "github-copilot";
const ANTHROPIC_FALLBACK_PROVIDER_IDS = new Set(["anthropic-api-key"]);
type ExpiredBannerProvider = { id: string; name: string };
@@ -90,14 +91,21 @@ export function OAuthReloginBanner({
if (providerId) {
setExpiredProviders((current) => current.filter((provider) => provider.id !== providerId));
setDismissedProviderIds((currentDismissed) => {
+ /*
+ FNXC:ProviderAuth 2026-07-20-12:00:
+ FN-8446 — Polling a healthy status must never prune a browser's
+ dismissal preference. GitHub Copilot remains dismissed permanently
+ because its short-lived session can repeatedly trigger successful-login
+ events; other providers re-arm after an explicit successful re-login.
+ */
+ if (providerId === GITHUB_COPILOT_PROVIDER_ID) {
+ return 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);
diff --git a/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx b/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx
index 23044166c4..182162ef3a 100644
--- a/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx
+++ b/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx
@@ -195,7 +195,7 @@ describe("OAuthReloginBanner", () => {
expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("openai-codex");
});
- it("re-arms a dismissed provider after its successful OAuth re-login", async () => {
+ it("keeps GitHub Copilot dismissed after a successful OAuth re-login", async () => {
mockFetchAuthStatus
.mockResolvedValueOnce({
providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }],
@@ -218,11 +218,42 @@ describe("OAuthReloginBanner", () => {
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "github-copilot" } }));
await flushPromises();
});
- expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).not.toContain("github-copilot");
+ expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot");
firstRender.unmount();
render();
- expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot");
+ await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(3));
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("re-arms other dismissed providers after a successful OAuth re-login", async () => {
+ mockFetchAuthStatus
+ .mockResolvedValueOnce({
+ providers: [{ id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true }],
+ ghCli: { available: false, authenticated: false },
+ })
+ .mockResolvedValueOnce({
+ providers: [{ id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: true, expired: false }],
+ ghCli: { available: false, authenticated: false },
+ })
+ .mockResolvedValueOnce({
+ providers: [{ id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true }],
+ ghCli: { available: false, authenticated: false },
+ });
+
+ const firstRender = render();
+ expect(await screen.findByRole("status")).toHaveTextContent("OpenAI Codex");
+ fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner"));
+
+ await act(async () => {
+ window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "openai-codex" } }));
+ await flushPromises();
+ });
+ expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).not.toContain("openai-codex");
+
+ firstRender.unmount();
+ render();
+ expect(await screen.findByRole("status")).toHaveTextContent("OpenAI Codex");
});
it("does not clear dismissed providers for an OAuth success event without a provider id", async () => {