From d645e2e63a8b989f743742407e68c59c3d65b50d Mon Sep 17 00:00:00 2001 From: Ali Khokhar <20476625+Alishahryar1@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:29:01 -0700 Subject: [PATCH 1/2] minor: Add native VS Code Chat integration (#1915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Let users select FCC models directly in VS Code's built-in Chat and Agent mode. Native clients also need a consistent fallback when providers omit context metadata, while keeping output allowances and reasoning effort distinct. ## How Add a VS Code Chat integration card with Connect, Disconnect, an actionable Retry after update failures, and a modal showing the configuration path. Reconcile failures and recovery even when polling misses the updating phase or a status read is already in flight, without replaying reload notices. Generate its Custom Endpoint model group from FCC's application catalog and reconcile connected configurations when models or settings change. Use a shared 200,000-token context fallback at client serialization boundaries. Direct model discovery includes whether context came from the provider or the default, so launcher decoding preserves unknown metadata and OpenCode retains its existing output-reserve policy. Apply the fallback to supported Codex, VS Code, Pi, OpenCode, DSH, Aider, Cline, Hermes and Muse catalog fields. Expose VS Code's native effort picker using shared Codex effort labels and the existing FCC reasoning pipeline. Prefer reported output limits, otherwise allocate up to 81,920 tokens, capped at half the context to leave room for input. These are output ceilings, not effort-dependent thinking budgets. Accepted configuration contracts: - Target the standard VS Code default profile on Windows, macOS and Linux. Users select their own chat and utility models. - FCC owns the generated model inventory and capabilities. Refresh replaces manual edits inside generated model definitions. Preserve native effort preferences in `group.settings`, the group name, unrelated group properties and other providers' groups. - Use Messages and explicit generated models. Store FCC's managed local token in supported per-model request headers. VS Code treats the group-level `apiKey` as a secret-store reference. - Use private POSIX file permissions on replacement and repair permissions on unchanged refreshes. Permission-only repairs do not produce settings-reload notices. Windows retains the existing user-profile directory ACL policy. - Preserve JSON values rather than comments or formatting. FCC serializes its own writes and replaces files atomically. External editors do not participate in its lock. - Reported context wins over the fallback. Native clients without a supported per-model context setting, including the Claude protocol clients, keep their native behavior. Other harnesses retain their existing output and effort policies. Correct the browser-shutdown test to coordinate readiness under its existing parent-process deadline. Isolate the real thread-diagnostic integration test in a bounded subprocess with stage output. The previous macOS blocking operation remains unconfirmed, so this supplies diagnostic evidence without changing the helper speculatively. Use synchronous request-arrival counters in browser tests and drain the VS Code retry test's forwarding callbacks before context disposal. Give server-fixture shutdown one thirty-second watchdog, report thread and async-task diagnostics after five seconds, and stop subsequent tests if the owned server remains alive. This accommodates the existing native-interrupt grace period while preserving evidence of the unexplained Windows SQLite delay. Validation: 6,453 pytest tests and 221 Playwright tests passed locally, alongside Ruff and ty. Native VS Code 1.139.1 with NVIDIA NIM sent low/high/none effort and the configured output cap, created a file, and completed a follow-up edit. Cross-platform CI will validate the pushed commit, including the macOS diagnostic boundary.

RetriggerConfidence Score: 5/5

No blocking issue remains. The outstanding shutdown-cleanup concern is non-blocking.
Summary The PR adds native VS Code Chat integration and a shared context fallback for supported clients. The latest change adds VS Code Chat to the README’s coding-agent list. No new actionable issue was found in that change.
Reviews (7) · Last reviewed commit: ["Count VS Code Chat as the eleventh codin..."](https://github.com/alishahryar1/free-claude-code/commit/9d8eb5f7c5ecadc265bf1ce0b8ef50be8c28a03c) --- README.md | 3 +- e2e/conftest.py | 13 +- e2e/server_shutdown.py | 90 ++++++ e2e/test_admin_integrations.py | 23 +- e2e/test_admin_providers.py | 15 +- e2e/test_admin_vscode_chat.py | 92 ++++++ e2e/test_integration_notices.py | 91 ++++++ src/free_claude_code/api/admin_routes.py | 36 +++ .../api/admin_static/admin.css | 12 +- .../api/admin_static/admin.js | 112 ++++++- .../api/admin_static/index.html | 26 ++ src/free_claude_code/api/model_catalog.py | 15 +- src/free_claude_code/api/ports.py | 8 + .../application/model_catalog.py | 9 + .../cli/launchers/aider_config.py | 9 +- .../cli/launchers/catalog_http.py | 6 +- .../cli/launchers/cline_config.py | 8 +- .../cli/launchers/dsh_config.py | 8 +- .../cli/launchers/hermes_config.py | 8 +- .../cli/launchers/opencode_config.py | 8 +- .../cli/launchers/pi_extension.ts | 11 +- src/free_claude_code/config/constants.py | 3 + .../harnesses/codex_model_catalog.py | 36 +-- src/free_claude_code/harnesses/config_file.py | 12 +- .../harnesses/model_policy.py | 21 ++ .../harnesses/vscode_chat_integration.py | 150 +++++++++ src/free_claude_code/runtime/application.py | 123 ++++++++ src/free_claude_code/runtime/diagnostics.py | 4 + .../runtime/provider_manager.py | 7 + tests/api/test_model_listing.py | 41 ++- tests/api/test_vscode_chat_routes.py | 85 ++++++ tests/cli/test_aider_config.py | 2 + tests/cli/test_cline_launcher.py | 1 + tests/cli/test_dsh_config.py | 2 + tests/cli/test_hermes_config.py | 2 + tests/cli/test_opencode_launcher.py | 7 +- tests/cli/test_pi_extension.py | 7 +- tests/cli/test_progressive_launch.py | 41 ++- tests/conftest.py | 6 + tests/contracts/test_import_boundaries.py | 2 + .../harnesses/test_vscode_chat_integration.py | 288 ++++++++++++++++++ tests/runtime/test_vscode_chat_sync.py | 241 +++++++++++++++ tests/test_browser_server_shutdown.py | 135 ++++++++ tests/test_thread_diagnostics.py | 49 ++- 44 files changed, 1770 insertions(+), 98 deletions(-) create mode 100644 e2e/server_shutdown.py create mode 100644 e2e/test_admin_vscode_chat.py create mode 100644 src/free_claude_code/harnesses/model_policy.py create mode 100644 src/free_claude_code/harnesses/vscode_chat_integration.py create mode 100644 tests/api/test_vscode_chat_routes.py create mode 100644 tests/harnesses/test_vscode_chat_integration.py create mode 100644 tests/runtime/test_vscode_chat_sync.py create mode 100644 tests/test_browser_server_shutdown.py diff --git a/README.md b/README.md index 9425d44554..1c48940b0c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ## What You Get - **55 ToS-friendly providers. 1.3B+ free tokens every month.** Use free, paid, subscription, and local models from one searchable UI without putting your account at risk. FCC follows provider terms and removes integrations if they stop being allowed. -- **10 coding agents. One model catalog.** Run [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://github.com/openai/codex), [Pi](https://github.com/earendil-works/pi), [OpenCode](https://github.com/anomalyco/opencode), [Cline](https://github.com/cline/cline), [Hermes](https://github.com/NousResearch/hermes-agent), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [Grok Build](https://github.com/xai-org/grok-build), [Muse Code](https://research.meta.ai/blog/introducing-muse-code-and-muse-spark-1-2/), or [Aider](https://aider.chat/) with your FCC models. +- **11 coding agents. One model catalog.** Run [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://github.com/openai/codex), [Pi](https://github.com/earendil-works/pi), [OpenCode](https://github.com/anomalyco/opencode), [Cline](https://github.com/cline/cline), [Hermes](https://github.com/NousResearch/hermes-agent), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [Grok Build](https://github.com/xai-org/grok-build), [Muse Code](https://research.meta.ai/blog/introducing-muse-code-and-muse-spark-1-2/), [Aider](https://aider.chat/), or [VS Code Chat](https://code.visualstudio.com/) with your FCC models. - **Keep coding through provider outages.** After retries are exhausted, FCC automatically tries your next configured model without making you restart the turn. This works across every client. - **Up to 90% fewer terminal-output tokens.** Optional [RTK](https://github.com/rtk-ai/rtk) filters common command output, while five FCC optimizations handle quota probes, command-prefix detection, titles, suggestions, and filepaths without calling a provider. - **Native Code sessions in your browser.** Choose a folder and run Codex in the browser with real-time and background support. Freely switch providers/models in the same session. Support for switching harnesses in the same session coming soon! @@ -359,6 +359,7 @@ For editor and app integrations, install the client, start FCC, then open **Admin UI → Integrations** and click **Connect** on its card. - **Claude Code in VS Code**: Install the [Claude Code extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code). +- **VS Code Chat**: Install the latest [VS Code](https://code.visualstudio.com/), connect, and select an FCC model in its built-in Chat model picker. - **Claude Desktop**: Install [Claude Desktop](https://claude.ai/download). Fully quit it before connecting or disconnecting, then reopen it. Disconnect returns to normal Claude sign-in. - **Codex in VS Code and App**: Install the [Codex extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt) or Codex App. - **Claude Code in JetBrains ACP**: Install Claude Agent in JetBrains AI Assistant and start it once, then click **Connect** in FCC. Reopen the IDE, select **Claude Code (FCC)**, and start a new chat. After JetBrains updates the agent, restart FCC before starting a new chat. Requires a local IDE in its standard installation locations. diff --git a/e2e/conftest.py b/e2e/conftest.py index 2368f22f06..76903e3cfb 100644 --- a/e2e/conftest.py +++ b/e2e/conftest.py @@ -1,7 +1,6 @@ """Isolated browser-test composition for the local Admin UI.""" import asyncio -import faulthandler import socket import sys import threading @@ -14,6 +13,7 @@ from playwright.sync_api import Page from e2e.code_support import CodeControl +from e2e.server_shutdown import join_server from free_claude_code.api.app import create_app from free_claude_code.api.ports import ApiServices from free_claude_code.application.model_metadata import ProviderModelInfo @@ -33,6 +33,7 @@ claude_integration, codex_integration, jetbrains_acp_integration, + vscode_chat_integration, ) from free_claude_code.providers.base import BaseProvider, ProviderConfig from free_claude_code.providers.runtime import ProviderRuntime @@ -185,6 +186,11 @@ def admin_base_url( monkeypatch.setattr( codex_integration, "config_path", lambda: tmp_path / ".codex" / "config.toml" ) + monkeypatch.setattr( + vscode_chat_integration, + "config_path", + lambda: tmp_path / "vscode/chatLanguageModels.json", + ) monkeypatch.setattr( claude_integration, "claude_state_path", lambda: tmp_path / ".claude.json" ) @@ -346,12 +352,9 @@ def run_server() -> None: yield f"http://127.0.0.1:{port}" finally: server.should_exit = True - thread.join(timeout=5.0) + join_server(thread, code_control, request) listener.close() clear_settings_cache() - if thread.is_alive(): - faulthandler.dump_traceback(file=sys.stderr) - pytest.fail("Admin browser-test server did not stop") @pytest.fixture(autouse=True) diff --git a/e2e/server_shutdown.py b/e2e/server_shutdown.py new file mode 100644 index 0000000000..cf131786a8 --- /dev/null +++ b/e2e/server_shutdown.py @@ -0,0 +1,90 @@ +"""Bounded cleanup for the browser fixture's server thread.""" + +import asyncio +import faulthandler +import inspect +import sys +import threading +import time +from concurrent.futures import Future + +import pytest + +from e2e.code_support import CodeControl + + +def _task_snapshot(control: CodeControl) -> str: + jobs = control.service._jobs + lines = [f"Code jobs: {sum(not job.done() for job in jobs)}"] + for task in asyncio.all_tasks(): + lines.append(f"Task {task.get_name()} (Code job: {task in jobs}):") + current = task.get_coro() + while inspect.iscoroutine(current) or inspect.isgenerator(current): + frame = ( + current.cr_frame if inspect.iscoroutine(current) else current.gi_frame + ) + if frame is not None: + lines.append( + f" {frame.f_code.co_filename}:{frame.f_lineno} in {frame.f_code.co_name}" + ) + current = ( + current.cr_await + if inspect.iscoroutine(current) + else current.gi_yieldfrom + ) + return "\n".join(lines) + + +def join_server( + thread: threading.Thread, + control: CodeControl, + request: pytest.FixtureRequest, + *, + diagnostic_after: float = 5, + timeout: float = 30, +) -> None: + # Native interruption can itself take five seconds before durable cleanup. + # Diagnose at that point, but give the entire fixture shutdown its own budget. + started = time.monotonic() + deadline = started + timeout + thread.join(min(diagnostic_after, timeout)) + if not thread.is_alive(): + return + + print( + f"Server shutdown still pending after {time.monotonic() - started:.2f}s: " + f"{request.node.nodeid}", + file=sys.stderr, + ) + faulthandler.dump_traceback(file=sys.stderr) + snapshot: Future[str] = Future() + + def capture() -> None: + try: + snapshot.set_result(_task_snapshot(control)) + except Exception as error: + snapshot.set_result(f"Server task snapshot failed: {type(error).__name__}") + + if control.loop is None: + snapshot.set_result("Server loop unavailable for task snapshot") + else: + try: + control.loop.call_soon_threadsafe(capture) + except RuntimeError: + snapshot.set_result("Server loop closed before task snapshot") + + thread.join(max(0, deadline - time.monotonic())) + print( + snapshot.result() + if snapshot.done() + else "Server loop did not provide a task snapshot", + file=sys.stderr, + ) + if thread.is_alive(): + reason = ( + f"Admin browser-test server did not stop within {timeout:g}s: " + f"{request.node.nodeid}" + ) + request.session.shouldstop = reason + faulthandler.dump_traceback(file=sys.stderr) + pytest.fail(reason) diff --git a/e2e/test_admin_integrations.py b/e2e/test_admin_integrations.py index a717468f4d..a5081668be 100644 --- a/e2e/test_admin_integrations.py +++ b/e2e/test_admin_integrations.py @@ -25,7 +25,7 @@ def test_connection_check_uses_disabled_loading_button( def hold_check(route): pending.append(route) - page.evaluate("window.integrationCheckIntercepted = true") + page.evaluate("window.integrationChecks = (window.integrationChecks || 0) + 1") page.route(f"**/admin/api/integrations/{integration}", hold_check) page.goto(f"{admin_base_url}/admin/integrations") @@ -33,7 +33,6 @@ def hold_check(route): for visit in range(2): if visit: page.get_by_role("button", name="Providers", exact=True).click() - page.evaluate("window.integrationCheckIntercepted = false") page.get_by_role("button", name="Integrations", exact=True).click() expect(button).to_be_disabled() expect(button).to_have_text("Loading…") @@ -46,9 +45,17 @@ def hold_check(route): == "integration-spinner" ) expect(page.locator("#view-integrations .status-pill")).to_have_count(0) - page.wait_for_function("window.integrationCheckIntercepted === true") + page.wait_for_function( + "count => window.integrationChecks >= count", arg=visit + 1 + ) assert len(pending) == 1 - pending.pop().fulfill(json={"connected": connected, "paths": None}) + pending.pop().fulfill( + json={ + "connected": connected, + "paths": None, + "update": {"state": "ready", "changed": False, "message": None}, + } + ) expect(button).to_be_enabled() expect(button).to_have_text("Disconnect" if connected else "Connect") expect(button).to_have_attribute("aria-busy", "false") @@ -63,13 +70,10 @@ def test_codex_connect_disconnect_and_modal_paths( expect(page.locator("#openClaudeIntegration")).to_be_enabled() expect(page.locator("#messageArea")).to_have_text("") cards = page.locator("#view-integrations > article") - expect(cards).to_have_count(4) + expect(cards.first).to_be_visible() expect(page.locator("#claudeIntegrationStatus")).to_have_count(0) expect(page.locator("#openCodexIntegration")).to_be_enabled() expect(page.locator("#codexIntegrationStatus")).to_have_count(0) - expect(cards.nth(1)).to_contain_text( - "Use FCC's models in the Codex VS Code extension and desktop app." - ) bounds = [card.bounding_box() for card in cards.all()] if width >= 1200: descriptions = [ @@ -124,8 +128,7 @@ def test_codex_connect_disconnect_and_modal_paths( expect(opener).to_have_css("color", "rgb(239, 68, 68)") if width >= 1200: buttons = [ - button.bounding_box() - for button in page.locator(".integration-card > button").all() + card.get_by_role("button").first.bounding_box() for card in cards.all() ] for _, row in groupby(range(len(bounds)), key=lambda index: bounds[index]["y"]): assert len({buttons[index]["y"] for index in row}) == 1 diff --git a/e2e/test_admin_providers.py b/e2e/test_admin_providers.py index 76197b6783..009581ea31 100644 --- a/e2e/test_admin_providers.py +++ b/e2e/test_admin_providers.py @@ -173,18 +173,19 @@ def hold_first_status(route: Route) -> None: route.continue_() else: startup.append(route) + page.evaluate("window.startupRequests = (window.startupRequests || 0) + 1") + + def hold_manual(route: Route) -> None: + manual.append(route) + page.evaluate("window.manualRequests = (window.manualRequests || 0) + 1") page.route("**/admin/api/status", hold_first_status) - page.route( - "**/admin/api/providers/open_router/test", lambda route: manual.append(route) - ) - page.expose_function("startupRequestIntercepted", lambda: bool(startup)) - page.expose_function("manualRequestIntercepted", lambda: bool(manual)) + page.route("**/admin/api/providers/open_router/test", hold_manual) page.evaluate("void refreshStartup()") - page.wait_for_function("window.startupRequestIntercepted()") + page.wait_for_function("window.startupRequests >= 1") dialog = open_provider(page, "open_router") dialog.get_by_role("button", name="Refresh models", exact=True).click() - page.wait_for_function("window.manualRequestIntercepted()") + page.wait_for_function("window.manualRequests >= 1") expected = "Checking..." if manual_result != "pending": manual.pop().fulfill( diff --git a/e2e/test_admin_vscode_chat.py b/e2e/test_admin_vscode_chat.py new file mode 100644 index 0000000000..6275886b80 --- /dev/null +++ b/e2e/test_admin_vscode_chat.py @@ -0,0 +1,92 @@ +import json + +import pytest +from playwright.sync_api import expect + +from free_claude_code.harnesses import vscode_chat_integration as vscode + + +def test_connect_and_disconnect_native_chat(page, admin_base_url): + path = vscode.config_path() + path.parent.mkdir(parents=True, exist_ok=True) + other = {"vendor": "customendpoint", "name": "Other", "models": []} + path.write_text(json.dumps([other])) + page.goto(f"{admin_base_url}/admin/integrations") + button = page.locator("#openVSCodeChatIntegration") + dialog = page.locator("#vscodeChatIntegrationDialog") + expect(button).to_have_text("Connect") + button.click() + expect(dialog).to_be_visible() + expect(dialog.locator("code")).to_have_text(str(path.resolve())) + page.keyboard.press("Escape") + expect(dialog).not_to_be_visible() + button.click() + dialog.get_by_role("button", name="Close", exact=True).click() + expect(dialog).not_to_be_visible() + button.click() + dialog.get_by_role("button", name="Connect", exact=True).click() + expect(button).to_have_text("Disconnect") + expect(button).to_have_class("danger-button") + group = json.loads(path.read_text())[1] + assert group["apiType"] == "messages" + assert group["models"] + assert all(m["url"].endswith("/v1/messages") for m in group["models"]) + page.reload() + expect(button).to_have_text("Disconnect") + expect(page.locator("#vscodeChatIntegrationMessage")).not_to_be_visible() + button.click() + dialog.get_by_role("button", name="Disconnect", exact=True).click() + expect(button).to_have_text("Connect") + assert json.loads(path.read_text()) == [other] + + +@pytest.mark.parametrize("status_read_failed", [False, True]) +def test_failed_update_has_actionable_retry(page, admin_base_url, status_read_failed): + progress: dict[str, str | bool | None] = { + "state": "failed", + "changed": False, + "message": "Could not update settings.", + } + retries = [] + + def startup(route): + response = route.fetch() + payload = response.json() + payload["startup"]["integrations"]["vscode-chat"] = dict(progress) + route.fulfill(response=response, json=payload) + + def status(route): + if status_read_failed and progress["state"] == "failed": + route.fulfill(status=503, json={"detail": "Could not read settings."}) + else: + route.fulfill( + json={"connected": True, "paths": None, "update": dict(progress)} + ) + + def retry(route): + retries.append(route.request.method) + progress.update(state="ready", message=None) + route.fulfill(json={"update": dict(progress)}) + + try: + page.route("**/admin/api/status", startup) + page.route("**/admin/api/integrations/vscode-chat", status) + page.route("**/admin/api/integrations/vscode-chat/refresh", retry) + page.goto(f"{admin_base_url}/admin/integrations") + main = page.locator("#openVSCodeChatIntegration") + secondary = page.locator("#retryVSCodeChatIntegration") + if status_read_failed: + expect(main).to_have_text("Retry") + action = main + else: + expect(main).to_have_text("Disconnect") + expect(secondary).to_be_visible() + action = secondary + with page.expect_response("**/admin/api/integrations/vscode-chat/refresh"): + action.click() + assert retries == ["POST"] + expect(main).to_have_text("Disconnect") + expect(secondary).to_be_hidden() + expect(page.locator("#vscodeChatIntegrationMessage")).to_be_hidden() + finally: + page.unroute_all(behavior="wait") diff --git a/e2e/test_integration_notices.py b/e2e/test_integration_notices.py index 9525c6dc2c..eb83f3386e 100644 --- a/e2e/test_integration_notices.py +++ b/e2e/test_integration_notices.py @@ -4,6 +4,7 @@ @pytest.fixture( params=[ + ("vscode-chat", "openVSCodeChatIntegration", "vscodeChatIntegrationMessage"), ("claude-vscode", "openClaudeIntegration", "claudeIntegrationMessage"), ("codex", "openCodexIntegration", "codexIntegrationMessage"), ("jetbrains-acp", "openJetBrainsIntegration", "jetBrainsIntegrationMessage"), @@ -83,6 +84,68 @@ def test_notices_require_a_live_changed_completion(page, notice): expect(message).not_to_have_text("Update failed") +def test_settled_poll_after_mutation_preserves_next_action(page, notice): + integration, button, message, observe = notice + observe("ready") + page.wait_for_function("!state.startupRequest && state.startupTimer === null") + unexpected_reads = [] + + def fail_status(route): + unexpected_reads.append(route.request) + route.fulfill(status=503, json={"detail": "Status unavailable"}) + + endpoint = f"**/admin/api/integrations/{integration}" + page.route(endpoint, fail_status) + dialog = page.get_by_role("dialog") + for action, connected in [("Disconnect", False), ("Connect", True)]: + page.route( + f"{endpoint}/{action.lower()}", + lambda route: route.fulfill( + json={ + "connected": route.request.url.endswith("/connect"), + "disconnect_pending": False, + "paths": None, + } + ), + ) + button.click() + dialog.get_by_role("button", name=action, exact=True).click() + expect(dialog).not_to_be_visible() + next_action = "Disconnect" if connected else "Connect" + expect(button).to_have_text(next_action) + button.click() + observe("ready") + expect( + dialog.get_by_role("button", name=next_action, exact=True) + ).to_be_enabled() + assert not unexpected_reads + dialog.get_by_role("button", name="Close", exact=True).click() + page.unroute(endpoint, fail_status) + observe("failed") + expect(message).to_have_text("Update failed") + observe("ready") + expect(message).to_be_hidden() + + +def test_missed_starting_state_still_reconciles_failure_and_recovery(page, notice): + integration, button, message, observe = notice + expect(message).to_be_hidden() + observe("failed") + expect(message).to_have_text("Update failed") + expect(button).to_be_enabled() + if integration == "vscode-chat": + expect(button).to_have_text("Disconnect") + expect(page.locator("#retryVSCodeChatIntegration")).to_be_visible() + observe("ready", changed=True) + expect(message).to_be_hidden() + expect(button).to_be_enabled() + if integration == "vscode-chat": + expect(page.locator("#retryVSCodeChatIntegration")).to_be_hidden() + # A repeated settled snapshot must not replay a reload notification. + observe("ready", changed=True) + expect(message).to_be_hidden() + + @pytest.mark.parametrize("revisit_while_busy", [False, True]) def test_busy_status_recovery_preserves_notice_without_replaying_it( page, notice, revisit_while_busy @@ -149,3 +212,31 @@ def test_new_server_and_off_page_completions_stay_quiet(page, notice): page.get_by_role("button", name="Integrations", exact=True).click() expect(button).to_be_enabled() expect(message).to_be_hidden() + + +def test_missed_transition_during_status_read_is_reconciled(page, notice): + integration, button, message, observe = notice + pending = [] + + def hold_status(route): + pending.append(route) + + pattern = f"**/admin/api/integrations/{integration}" + page.route(pattern, hold_status) + page.get_by_role("button", name="Providers", exact=True).click() + with page.expect_request(pattern): + page.get_by_role("button", name="Integrations", exact=True).click() + expect(button).to_be_disabled() + page.wait_for_function("!state.startupRequest && state.startupTimer === null") + observe("failed") + assert len(pending) == 1 + page.unroute(pattern, hold_status) + pending[0].fulfill( + json={ + "connected": True, + "paths": None, + "update": {"state": "ready", "changed": False, "message": None}, + } + ) + expect(message).to_have_text("Update failed") + expect(button).to_be_enabled() diff --git a/src/free_claude_code/api/admin_routes.py b/src/free_claude_code/api/admin_routes.py index 698a4f0c39..59c58de910 100644 --- a/src/free_claude_code/api/admin_routes.py +++ b/src/free_claude_code/api/admin_routes.py @@ -273,6 +273,42 @@ async def claude_vscode_status( return await _integration_response(services.admin.claude_vscode_status) +@router.get("/admin/api/integrations/vscode-chat") +async def vscode_chat_status( + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return await _integration_response(services.admin.vscode_chat_status) + + +@router.post("/admin/api/integrations/vscode-chat/connect") +async def connect_vscode_chat( + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return await _integration_response(services.admin.connect_vscode_chat) + + +@router.post("/admin/api/integrations/vscode-chat/disconnect") +async def disconnect_vscode_chat( + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return await _integration_response(services.admin.disconnect_vscode_chat) + + +@router.post("/admin/api/integrations/vscode-chat/refresh") +async def refresh_vscode_chat( + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return await _integration_response(services.admin.refresh_vscode_chat) + + @router.post("/admin/api/integrations/claude-vscode/connect") async def connect_claude_vscode( request: Request, diff --git a/src/free_claude_code/api/admin_static/admin.css b/src/free_claude_code/api/admin_static/admin.css index 661cad283d..a0b5852fec 100644 --- a/src/free_claude_code/api/admin_static/admin.css +++ b/src/free_claude_code/api/admin_static/admin.css @@ -266,19 +266,27 @@ textarea:focus-visible, align-items: flex-start; margin-bottom: 0; } -.integration-card > button { +.integration-actions { + display: flex; + gap: 8px; + margin-top: auto; +} +.integration-card > button, +.integration-actions > button { display: inline-flex; align-items: center; gap: 8px; margin-top: auto; } .integration-card > button, +.integration-actions > button, .provider-card > .provider-actions > button { min-height: 38px; padding: 6px 14px; font-size: 13px; } .integration-card > button[aria-busy="true"]::before, +.integration-actions > button[aria-busy="true"]::before, button.startup-busy::before, .startup-spinner::before { content: ""; @@ -293,7 +301,7 @@ button.startup-busy::before, to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { - .integration-card > button[aria-busy="true"]::before { animation: none; } + .integration-card button[aria-busy="true"]::before { animation: none; } } .integration-card h3 { min-height: 2lh; } .integration-dialog { diff --git a/src/free_claude_code/api/admin_static/admin.js b/src/free_claude_code/api/admin_static/admin.js index 4eeab6461d..e7281f087a 100644 --- a/src/free_claude_code/api/admin_static/admin.js +++ b/src/free_claude_code/api/admin_static/admin.js @@ -141,6 +141,7 @@ function renderStartup() { renderProviderCheckResult(provider.provider_id); }); renderClaudeIntegration(); + renderVSCodeChatIntegration(); renderJetBrainsIntegration(); renderCodexIntegration(); renderClaudeDesktopIntegration(); @@ -291,6 +292,7 @@ function setActiveView(viewId, { scroll = false } = {}) { else window.CodeSessions.deactivate(); if (activeView.id === "integrations") { refreshClaudeIntegration(); + refreshVSCodeChatIntegration(); refreshJetBrainsIntegration(); refreshCodexIntegration(); refreshClaudeDesktopIntegration(); @@ -1606,15 +1608,22 @@ function integrationUpdating(integration, id) { function refreshIntegrationUpdates(previous, current) { if (state.activeView !== "integrations") return; for (const [id, integration, refresh, messageId, message] of [ + ["vscode-chat", vscodeChatIntegration, refreshVSCodeChatIntegration, "vscodeChatIntegrationMessage", "Models updated in VS Code."], ["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; + // Successful actions clear update metadata, not the last observed state. + const observedPhase = integration.update?.state ?? previous?.[id]?.state; + // Polling can miss a short update. Reconcile state independently of notices. if (phase && phase !== "starting" && ( - previous?.[id]?.state === "starting" || integration.update?.state === "starting" - )) void refresh(false, { background: true }); + previous?.[id]?.state === "starting" || observedPhase !== phase + )) { + if (integration.busy) state.startupAgain = true; + else void refresh(false, { background: true }); + } if (previous?.[id]?.state === "starting" && phase === "ready" && current[id].changed === true) { integrationMessage(messageId, message); } @@ -1713,6 +1722,105 @@ claudeIntegrationDialog.addEventListener("click", (event) => { } }); +const vscodeChatIntegrationDialog = byId("vscodeChatIntegrationDialog"); +const vscodeChatIntegration = { connected: null, busy: false, paths: null, update: null }; +const vscodeChatIntegrationPath = "/admin/api/integrations/vscode-chat"; + +function renderVSCodeChatIntegration() { + const { connected, paths } = vscodeChatIntegration; + const busy = vscodeChatIntegration.busy || integrationUpdating(vscodeChatIntegration, "vscode-chat"); + byId("retryVSCodeChatIntegration").hidden = connected === null || vscodeChatIntegration.update?.state !== "failed"; + byId("retryVSCodeChatIntegration").disabled = busy; + const action = connected ? "Disconnect" : "Connect"; + byId("openVSCodeChatIntegration").textContent = busy ? "Loading…" : connected === null ? "Retry" : action; + byId("openVSCodeChatIntegration").disabled = busy; + byId("openVSCodeChatIntegration").setAttribute("aria-busy", String(busy)); + byId("confirmVSCodeChatIntegration").textContent = busy ? "Saving…" : action; + byId("confirmVSCodeChatIntegration").disabled = busy || connected === null; + byId("openVSCodeChatIntegration").className = connected && !busy ? "danger-button" : "primary-button"; + byId("confirmVSCodeChatIntegration").className = connected ? "danger-button" : "primary-button"; + byId("vscodeChatIntegrationDescription").textContent = connected + ? "Remove FCC's model group from VS Code Chat." + : "Add FCC models to VS Code Chat. Your selected models stay unchanged."; + const files = byId("vscodeChatIntegrationFiles"); + files.replaceChildren(); + if (paths) { + const targets = [paths.vscode_models]; + targets.forEach((path) => { + const item = document.createElement("li"); + const code = document.createElement("code"); + code.textContent = path; + item.appendChild(code); + files.appendChild(item); + }); + } +} + +async function refreshVSCodeChatIntegration(retry = false, { background = false } = {}) { + if (!background) integrationMessage("vscodeChatIntegrationMessage", ""); + if (vscodeChatIntegration.busy) return; + vscodeChatIntegration.busy = true; + renderVSCodeChatIntegration(); + try { + if (retry) await api(`${vscodeChatIntegrationPath}/refresh`, { method: "POST" }); + const result = await api(vscodeChatIntegrationPath); + vscodeChatIntegration.connected = result.connected; + vscodeChatIntegration.paths = result.paths; + vscodeChatIntegration.update = result.update; + if (result.update?.state === "failed") integrationMessage("vscodeChatIntegrationMessage", result.update.message, true); + else if (byId("vscodeChatIntegrationMessage").classList.contains("error")) integrationMessage("vscodeChatIntegrationMessage", ""); + } catch (error) { + vscodeChatIntegration.connected = null; + vscodeChatIntegration.update = null; + integrationMessage("vscodeChatIntegrationMessage", error.message, true); + } finally { + vscodeChatIntegration.busy = false; + renderVSCodeChatIntegration(); + if (vscodeChatIntegration.update?.state === "starting") void refreshStartup(); + } +} + +byId("openVSCodeChatIntegration").addEventListener("click", () => { + if (vscodeChatIntegration.connected === null) { + refreshVSCodeChatIntegration(true); + return; + } + integrationMessage("vscodeChatIntegrationDialogMessage", ""); + vscodeChatIntegrationDialog.showModal(); +}); +byId("retryVSCodeChatIntegration").addEventListener("click", () => refreshVSCodeChatIntegration(true)); +byId("confirmVSCodeChatIntegration").addEventListener("click", async () => { + if (byId("confirmVSCodeChatIntegration").disabled) return; + const disconnect = vscodeChatIntegration.connected; + vscodeChatIntegration.busy = true; + renderVSCodeChatIntegration(); + integrationMessage("vscodeChatIntegrationDialogMessage", ""); + integrationMessage("vscodeChatIntegrationMessage", ""); + try { + const result = await api(`${vscodeChatIntegrationPath}/${disconnect ? "disconnect" : "connect"}`, { method: "POST" }); + vscodeChatIntegration.connected = result.connected; + vscodeChatIntegration.update = null; + vscodeChatIntegrationDialog.close(); + integrationMessage("vscodeChatIntegrationMessage", disconnect + ? "Settings removed. Reload VS Code to disconnect." + : "Models saved. Select an FCC model in VS Code Chat. Reload VS Code if needed."); + } catch (error) { + integrationMessage("vscodeChatIntegrationDialogMessage", error.message, true); + integrationMessage("vscodeChatIntegrationMessage", error.message, true); + } finally { + vscodeChatIntegration.busy = false; + renderVSCodeChatIntegration(); + } +}); +byId("closeVSCodeChatIntegration").addEventListener("click", () => vscodeChatIntegrationDialog.close()); +vscodeChatIntegrationDialog.addEventListener("click", (event) => { + if (event.target !== vscodeChatIntegrationDialog) return; + const bounds = vscodeChatIntegrationDialog.getBoundingClientRect(); + if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) { + vscodeChatIntegrationDialog.close(); + } +}); + const codexIntegrationDialog = byId("codexIntegrationDialog"); const codexIntegration = { connected: null, busy: false, paths: null, update: null }; const codexIntegrationPath = "/admin/api/integrations/codex"; diff --git a/src/free_claude_code/api/admin_static/index.html b/src/free_claude_code/api/admin_static/index.html index 21bf4e76f9..1cf05d8d87 100644 --- a/src/free_claude_code/api/admin_static/index.html +++ b/src/free_claude_code/api/admin_static/index.html @@ -65,6 +65,32 @@

Providers