Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions e2e/test_admin_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
151 changes: 151 additions & 0 deletions e2e/test_integration_notices.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 25 additions & 19 deletions src/free_claude_code/api/admin_static/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -1539,19 +1545,19 @@ 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);
claudeIntegration.connected = result.connected;
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;
Expand Down Expand Up @@ -1640,19 +1646,19 @@ 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);
codexIntegration.connected = result.connected;
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;
Expand Down Expand Up @@ -1737,19 +1743,19 @@ 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);
jetBrainsIntegration.connected = result.connected;
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;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading