From bc987dcceb2d284d2c076e6a18670ca0550dd8ab Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Sun, 20 Sep 2026 19:44:39 -0700 Subject: [PATCH] Stop replaying integration update notices (#1849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Integration cards replay “Settings updated” on every visit because status responses retain the last update result. This makes an old update look like a new one. ## How Show the existing notice only when the visible Integrations page observes an update finish with changes, using the existing startup polling. Apply this to all four cards and keep notification display independent of overlapping card status requests. Revisiting or reloading clears the notice. Updates completed before the page observes them intentionally stay quiet. Keep errors and direct Connect/Disconnect messages, and bump the patch version to **6.2.53**. --- e2e/test_admin_integrations.py | 8 +- e2e/test_integration_notices.py | 151 ++++++++++++++++++ pyproject.toml | 2 +- .../api/admin_static/admin.js | 44 ++--- uv.lock | 2 +- 5 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 e2e/test_integration_notices.py diff --git a/e2e/test_admin_integrations.py b/e2e/test_admin_integrations.py index 1617e2bd70..b4657cda0b 100644 --- a/e2e/test_admin_integrations.py +++ b/e2e/test_admin_integrations.py @@ -374,6 +374,10 @@ def retry(route): button.click() expect(button).to_be_disabled() assert retries == ["POST"] + page.wait_for_function( + "id => state.startup?.startup?.integrations[id]?.state === 'starting'", + arg=integration, + ) progress.update(state="ready", changed=True) expect(button).to_have_text("Disconnect") expect(button).to_be_enabled() @@ -417,9 +421,7 @@ def test_startup_updates_before_opening_integrations( page.goto(f"{admin_base_url}/admin/integrations") button = page.locator("#openClaudeIntegration") expect(button).to_have_text("Disconnect") - expect(page.locator("#claudeIntegrationMessage")).to_have_text( - "Settings updated. Reload VS Code." - ) + expect(page.locator("#claudeIntegrationMessage")).to_be_hidden() saved = json.loads(admin_client_files.read_text()) assert saved["editor.fontSize"] == 15 assert any( diff --git a/e2e/test_integration_notices.py b/e2e/test_integration_notices.py new file mode 100644 index 0000000000..9525c6dc2c --- /dev/null +++ b/e2e/test_integration_notices.py @@ -0,0 +1,151 @@ +import pytest +from playwright.sync_api import expect + + +@pytest.fixture( + params=[ + ("claude-vscode", "openClaudeIntegration", "claudeIntegrationMessage"), + ("codex", "openCodexIntegration", "codexIntegrationMessage"), + ("jetbrains-acp", "openJetBrainsIntegration", "jetBrainsIntegrationMessage"), + ( + "claude-desktop", + "openClaudeDesktopIntegration", + "claudeDesktopIntegrationMessage", + ), + ] +) +def notice(page, admin_base_url, request): + integration, button_id, message_id = request.param + progress = {"state": "ready", "changed": True, "message": None} + server = {"instance_id": "first"} + + def startup(route): + response = route.fetch() + payload = response.json() + payload["instance_id"] = server["instance_id"] + payload["startup"]["integrations"][integration] = dict(progress) + route.fulfill(response=response, json=payload) + + def status(route): + route.fulfill(json={"connected": True, "paths": None, "update": dict(progress)}) + + page.route("**/admin/api/status", startup) + page.route(f"**/admin/api/integrations/{integration}", status) + page.goto(f"{admin_base_url}/admin/integrations") + button = page.locator(f"#{button_id}") + message = page.locator(f"#{message_id}") + expect(button).to_have_text("Disconnect") + + def observe(phase, changed=False, instance="first"): + progress.update( + state=phase, + changed=changed, + message="Update failed" if phase == "failed" else None, + ) + server["instance_id"] = instance + page.evaluate("() => refreshStartup()") + page.wait_for_function( + "([id, phase, changed, instance]) => state.startup?.instance_id === instance && state.startup.startup.integrations[id].state === phase && state.startup.startup.integrations[id].changed === changed", + arg=[integration, phase, changed, instance], + ) + + return integration, button, message, observe + + +def test_notices_require_a_live_changed_completion(page, notice): + _, button, message, observe = notice + expect(message).to_be_hidden() + observe("starting") + expect(button).to_be_disabled() + observe("ready", changed=True) + expect(message).to_be_visible() + expect(message).not_to_have_class("message-area error") + expect(button).to_be_enabled() + page.get_by_role("button", name="Providers", exact=True).click() + page.get_by_role("button", name="Integrations", exact=True).click() + expect(button).to_be_enabled() + expect(message).to_be_hidden() + observe("ready", changed=True) + expect(message).to_be_hidden() + page.reload() + expect(button).to_be_enabled() + expect(message).to_be_hidden() + observe("starting") + observe("ready", changed=False) + expect(button).to_be_enabled() + expect(message).to_be_hidden() + observe("starting") + observe("failed") + expect(message).to_have_text("Update failed") + observe("starting") + observe("ready", changed=True) + expect(message).to_be_visible() + expect(message).not_to_have_text("Update failed") + + +@pytest.mark.parametrize("revisit_while_busy", [False, True]) +def test_busy_status_recovery_preserves_notice_without_replaying_it( + page, notice, revisit_while_busy +): + integration, button, message, observe = notice + observe("starting") + pending = [] + + def hold_status(route): + pending.append(route) + page.evaluate("window.integrationStatusIntercepted = true") + + page.route(f"**/admin/api/integrations/{integration}", hold_status) + page.get_by_role("button", name="Providers", exact=True).click() + with page.expect_request(f"**/admin/api/integrations/{integration}"): + page.get_by_role("button", name="Integrations", exact=True).click() + page.wait_for_function("window.integrationStatusIntercepted === true") + observe("ready", changed=True) + expect(message).to_be_visible() + if revisit_while_busy: + page.get_by_role("button", name="Providers", exact=True).click() + page.get_by_role("button", name="Integrations", exact=True).click() + expect(message).to_be_hidden() + # Release a stale read after the completion poll. The subsequent recovery GET + # must restore the button without erasing or replaying the completion notice. + page.unroute(f"**/admin/api/integrations/{integration}") + page.route( + f"**/admin/api/integrations/{integration}", + lambda route: route.fulfill( + json={ + "connected": True, + "paths": None, + "update": {"state": "ready", "changed": True}, + } + ), + ) + assert len(pending) == 1 + pending[0].fulfill( + json={ + "connected": None, + "paths": None, + "update": {"state": "starting", "changed": False}, + } + ) + expect(button).to_be_enabled() + if revisit_while_busy: + expect(message).to_be_hidden() + else: + expect(message).to_be_visible() + page.get_by_role("button", name="Providers", exact=True).click() + page.get_by_role("button", name="Integrations", exact=True).click() + expect(message).to_be_hidden() + + +def test_new_server_and_off_page_completions_stay_quiet(page, notice): + _, button, message, observe = notice + observe("starting") + observe("ready", changed=True, instance="second") + expect(button).to_be_enabled() + expect(message).to_be_hidden() + observe("starting", instance="second") + page.get_by_role("button", name="Providers", exact=True).click() + observe("ready", changed=True, instance="second") + page.get_by_role("button", name="Integrations", exact=True).click() + expect(button).to_be_enabled() + expect(message).to_be_hidden() diff --git a/pyproject.toml b/pyproject.toml index eb825ca613..fc07d7c2e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.52" +version = "6.2.53" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" diff --git a/src/free_claude_code/api/admin_static/admin.js b/src/free_claude_code/api/admin_static/admin.js index 7acf3d4ee7..71f33d238d 100644 --- a/src/free_claude_code/api/admin_static/admin.js +++ b/src/free_claude_code/api/admin_static/admin.js @@ -165,7 +165,10 @@ async function refreshStartup() { )) return; state.startup = result; renderStartup(); - refreshIntegrationUpdates(previous?.startup?.integrations, current.integrations); + refreshIntegrationUpdates( + previous?.instance_id === result.instance_id ? previous.startup.integrations : null, + current.integrations, + ); const changed = !previous || previous.instance_id !== result.instance_id || JSON.stringify(previous.startup) !== JSON.stringify(current); if (changed) void hydrateModelOptions(); @@ -1498,16 +1501,19 @@ function integrationUpdating(integration, id) { function refreshIntegrationUpdates(previous, current) { if (state.activeView !== "integrations") return; - for (const [id, integration, refresh] of [ - ["claude-vscode", claudeIntegration, refreshClaudeIntegration], - ["jetbrains-acp", jetBrainsIntegration, refreshJetBrainsIntegration], - ["codex", codexIntegration, refreshCodexIntegration], - ["claude-desktop", claudeDesktopIntegration, refreshClaudeDesktopIntegration], + for (const [id, integration, refresh, messageId, message] of [ + ["claude-vscode", claudeIntegration, refreshClaudeIntegration, "claudeIntegrationMessage", "Settings updated. Reload VS Code."], + ["jetbrains-acp", jetBrainsIntegration, refreshJetBrainsIntegration, "jetBrainsIntegrationMessage", "Settings updated. Reopen JetBrains and start a new chat."], + ["codex", codexIntegration, refreshCodexIntegration, "codexIntegrationMessage", "Settings updated. Restart Codex."], + ["claude-desktop", claudeDesktopIntegration, refreshClaudeDesktopIntegration, "claudeDesktopIntegrationMessage", "Settings updated. Reopen Claude Desktop."], ]) { const phase = current?.[id]?.state; if (phase && phase !== "starting" && ( previous?.[id]?.state === "starting" || integration.update?.state === "starting" - )) void refresh(); + )) void refresh(false, { background: true }); + if (previous?.[id]?.state === "starting" && phase === "ready" && current[id].changed === true) { + integrationMessage(messageId, message); + } } } @@ -1539,11 +1545,11 @@ function renderClaudeIntegration() { } } -async function refreshClaudeIntegration(retry = false) { +async function refreshClaudeIntegration(retry = false, { background = false } = {}) { + if (!background) integrationMessage("claudeIntegrationMessage", ""); if (claudeIntegration.busy) return; claudeIntegration.busy = true; renderClaudeIntegration(); - integrationMessage("claudeIntegrationMessage", ""); try { if (retry) await api(`${claudeIntegrationPath}/refresh`, { method: "POST" }); const result = await api(claudeIntegrationPath); @@ -1551,7 +1557,7 @@ async function refreshClaudeIntegration(retry = false) { claudeIntegration.paths = result.paths; claudeIntegration.update = result.update; if (result.update?.state === "failed") integrationMessage("claudeIntegrationMessage", result.update.message, true); - else if (result.update?.changed) integrationMessage("claudeIntegrationMessage", "Settings updated. Reload VS Code."); + else if (byId("claudeIntegrationMessage").classList.contains("error")) integrationMessage("claudeIntegrationMessage", ""); } catch (error) { claudeIntegration.connected = null; claudeIntegration.update = null; @@ -1640,11 +1646,11 @@ function renderCodexIntegration() { } } -async function refreshCodexIntegration(retry = false) { +async function refreshCodexIntegration(retry = false, { background = false } = {}) { + if (!background) integrationMessage("codexIntegrationMessage", ""); if (codexIntegration.busy) return; codexIntegration.busy = true; renderCodexIntegration(); - integrationMessage("codexIntegrationMessage", ""); try { if (retry) await api(`${codexIntegrationPath}/refresh`, { method: "POST" }); const result = await api(codexIntegrationPath); @@ -1652,7 +1658,7 @@ async function refreshCodexIntegration(retry = false) { codexIntegration.paths = result.paths; codexIntegration.update = result.update; if (result.update?.state === "failed") integrationMessage("codexIntegrationMessage", result.update.message, true); - else if (result.update?.changed) integrationMessage("codexIntegrationMessage", "Settings updated. Restart Codex."); + else if (byId("codexIntegrationMessage").classList.contains("error")) integrationMessage("codexIntegrationMessage", ""); } catch (error) { codexIntegration.connected = null; codexIntegration.update = null; @@ -1737,11 +1743,11 @@ function renderJetBrainsIntegration() { } } -async function refreshJetBrainsIntegration(retry = false) { +async function refreshJetBrainsIntegration(retry = false, { background = false } = {}) { + if (!background) integrationMessage("jetBrainsIntegrationMessage", ""); if (jetBrainsIntegration.busy) return; jetBrainsIntegration.busy = true; renderJetBrainsIntegration(); - integrationMessage("jetBrainsIntegrationMessage", ""); try { if (retry) await api(`${jetBrainsIntegrationPath}/refresh`, { method: "POST" }); const result = await api(jetBrainsIntegrationPath); @@ -1749,7 +1755,7 @@ async function refreshJetBrainsIntegration(retry = false) { jetBrainsIntegration.paths = result.paths; jetBrainsIntegration.update = result.update; if (result.update?.state === "failed") integrationMessage("jetBrainsIntegrationMessage", result.update.message, true); - else if (result.update?.changed) integrationMessage("jetBrainsIntegrationMessage", "Settings updated. Reopen JetBrains and start a new chat."); + else if (byId("jetBrainsIntegrationMessage").classList.contains("error")) integrationMessage("jetBrainsIntegrationMessage", ""); } catch (error) { jetBrainsIntegration.connected = null; jetBrainsIntegration.update = null; @@ -1836,11 +1842,11 @@ function renderClaudeDesktopIntegration() { } } -async function refreshClaudeDesktopIntegration(retry = false) { +async function refreshClaudeDesktopIntegration(retry = false, { background = false } = {}) { + if (!background) integrationMessage("claudeDesktopIntegrationMessage", ""); if (claudeDesktopIntegration.busy) return; claudeDesktopIntegration.busy = true; renderClaudeDesktopIntegration(); - integrationMessage("claudeDesktopIntegrationMessage", ""); try { if (retry) await api(`${claudeDesktopIntegrationPath}/refresh`, { method: "POST" }); const result = await api(claudeDesktopIntegrationPath); @@ -1849,7 +1855,7 @@ async function refreshClaudeDesktopIntegration(retry = false) { claudeDesktopIntegration.paths = result.paths; claudeDesktopIntegration.update = result.update; if (result.update?.state === "failed") integrationMessage("claudeDesktopIntegrationMessage", result.update.message, true); - else if (result.update?.changed) integrationMessage("claudeDesktopIntegrationMessage", "Settings updated. Reopen Claude Desktop."); + else if (byId("claudeDesktopIntegrationMessage").classList.contains("error")) integrationMessage("claudeDesktopIntegrationMessage", ""); } catch (error) { claudeDesktopIntegration.connected = null; claudeDesktopIntegration.disconnectPending = false; diff --git a/uv.lock b/uv.lock index ead56c5a25..d6b7614d91 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.52" +version = "6.2.53" source = { editable = "." } dependencies = [ { name = "aiohttp" },