diff --git a/README.md b/README.md index 9425d44554..9e85fc0fef 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ ## 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. +- **56 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. +- **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! @@ -256,6 +256,7 @@ For another API endpoint, open **Providers → Custom providers → Add provider | [Lightning AI](https://lightning.ai/) | `LIGHTNING_API_KEY` | `lightning/lightning-ai/Qwen3.8-27B` | | [Experiential Labs](https://platform.experientiallabs.ai/) | `EXPLABS_API_KEY` | `experiential/union-alpha` | | [Cheaper Inference](https://cheaperinference.com/signup) | `CHEAPER_INFERENCE_API_KEY` | `cheaperinference/gpt-5.4-mini` | +| [OrcaRouter](https://www.orcarouter.ai/) | `ORCAROUTER_API_KEY` | `orcarouter/deepseek/deepseek-v4-flash-free` | | [Ollama Cloud](https://ollama.com/settings/keys) | `OLLAMA_API_KEY` | `ollama_cloud/qwen3-coder:480b` | | [LM Studio](https://lmstudio.ai/) | `LM_STUDIO_BASE_URL` | `lmstudio/` | | [llama.cpp](https://github.com/ggml-org/llama.cpp) | `LLAMACPP_BASE_URL` | `llamacpp/` | @@ -359,6 +360,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/smoke/lib/config.py b/smoke/lib/config.py index 594d52f144..3b00005d69 100644 --- a/smoke/lib/config.py +++ b/smoke/lib/config.py @@ -96,6 +96,7 @@ "lightning": "lightning/lightning-ai/Qwen3.8-27B", "experiential": "experiential/union-alpha", "cheaperinference": "cheaperinference/gpt-5.4-mini", + "orcarouter": "orcarouter/deepseek/deepseek-v4-flash-free", "agnes": "agnes/agnes-2.0-flash", "zenmux": "zenmux/deepseek/deepseek-v4-flash-free", "wandb": "wandb/openai/gpt-oss-20b", 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