diff --git a/app/cmd/app/app_darwin.go b/app/cmd/app/app_darwin.go index 6f5fd3f08..9e998bd98 100644 --- a/app/cmd/app/app_darwin.go +++ b/app/cmd/app/app_darwin.go @@ -401,7 +401,10 @@ func refreshClaudeDesktopCatalog(ctx context.Context, current []proxy.ClaudeDesk defer claudeCatalogMu.Unlock() claudeProxyMu.Lock() previous := append([]proxy.ClaudeDesktopModel(nil), claudeAvailableModels...) - fresh := len(claudeAvailableModels) > 0 && claudeCatalogNow().Before(claudeCatalogUpdated.Add(claudeCatalogRefreshInterval)) + // A local-only startup deliberately skips catalog discovery. Do not treat + // that selected-model snapshot as a complete catalog when Settings asks for + // all available choices. + fresh := hasCloudClaudeDesktopModel(claudeAvailableModels) && claudeCatalogNow().Before(claudeCatalogUpdated.Add(claudeCatalogRefreshInterval)) if !force && fresh { available = append([]proxy.ClaudeDesktopModel(nil), claudeAvailableModels...) source = claudeModelSource @@ -818,27 +821,66 @@ func claudeGatewayPortConflict() bool { return claudeProxyFail == claudeProxyFailurePortConflict } -func getClaudeDesktopConnectionStatus() claudeDesktopStatus { - used := hasUsedClaudeDesktopIntegration() +func getClaudeDesktopConnectionSummary() claudeDesktopStatus { + return claudeDesktopConnectionSummary(hasUsedClaudeDesktopIntegration()) +} + +func claudeDesktopRequestCount() uint64 { + claudeProxyMu.Lock() + gateway := claudeAppProxy + claudeProxyMu.Unlock() + if gateway == nil { + return 0 + } + return gateway.Counts().Routed +} + +func claudeDesktopConnectionSummary(used bool) claudeDesktopStatus { claudeProxyMu.Lock() proxyErr := claudeProxyErr proxyFailure := claudeProxyFail - gateway := claudeAppProxy claudeProxyMu.Unlock() + port := 0 + if portText, err := claudeGatewayPort(); err == nil { + port, _ = strconv.Atoi(portText) + } + installed := claudeDesktopInstalled() + configured := installed && claudeDesktop.UsesOllamaGateway() + status := claudeDesktopStatus{ + Supported: true, + Used: used, + Installed: installed, + Configured: configured, + Connected: configured && proxyErr == nil, + Running: launch.ClaudeDesktopRunning(), + StartFailed: proxyErr != nil, + PortConflict: proxyFailure == claudeProxyFailurePortConflict, + GatewayPort: port, + RoutedRequests: claudeDesktopRequestCount(), + } + if proxyErr != nil { + status.Error = proxyErr.Error() + } + return status +} + +func getClaudeDesktopConnectionStatus() claudeDesktopStatus { + used := hasUsedClaudeDesktopIntegration() var availableModels, selectedModels []proxy.ClaudeDesktopModel var modelSource string if used { + claudeProxyMu.Lock() + gateway := claudeAppProxy + claudeProxyMu.Unlock() var current []proxy.ClaudeDesktopModel if gateway != nil { current = gateway.Models() } - if len(current) > 0 && !hasCloudClaudeDesktopModel(current) { - availableModels = current - selectedModels = current - modelSource = "user" - } else { - availableModels, selectedModels, modelSource = refreshClaudeDesktopCatalog(context.Background(), current, false) - } + // Settings needs the complete catalog even when Claude is currently + // configured with only local models. The startup path can skip cloud + // discovery, but reusing that local-only list here would make every cloud + // choice disappear from the model picker. + availableModels, selectedModels, modelSource = refreshClaudeDesktopCatalog(context.Background(), current, false) } selected := make(map[string]struct{}) for _, model := range selectedModels { @@ -888,36 +930,17 @@ func getClaudeDesktopConnectionStatus() claudeDesktopStatus { }) } - port := 0 - if portText, err := claudeGatewayPort(); err == nil { - port, _ = strconv.Atoi(portText) - } - installed := claudeDesktopInstalled() - configured := installed && claudeDesktop.UsesOllamaGateway() - status := claudeDesktopStatus{ - Supported: true, - Used: used, - Installed: installed, - Configured: configured, - Connected: configured && proxyErr == nil, - Running: launch.ClaudeDesktopRunning(), - StartFailed: proxyErr != nil, - PortConflict: proxyFailure == claudeProxyFailurePortConflict, - GatewayPort: port, - ModelSource: modelSource, - Models: modelStatuses, - } - if proxyErr != nil { - status.Error = proxyErr.Error() - } + status := claudeDesktopConnectionSummary(used) + status.ModelSource = modelSource + status.Models = modelStatuses return status } -func setClaudeDesktopConnection(enabled bool) error { +func setClaudeDesktopConnection(enabled, restartConfirmed bool) error { if !claudeDesktopInstalled() { return errors.New("Claude Desktop is not installed") } - return setClaudeGatewayInstalled(enabled, launch.ClaudeDesktopRunning()) + return setClaudeGatewayInstalled(enabled, restartConfirmed) } func prepareClaudeDesktopConnection() error { diff --git a/app/cmd/app/app_darwin_test.go b/app/cmd/app/app_darwin_test.go index ea1398ae1..39b09ee8b 100644 --- a/app/cmd/app/app_darwin_test.go +++ b/app/cmd/app/app_darwin_test.go @@ -392,6 +392,7 @@ type fakeClaudeDesktopController struct { setErr error modelsAtSet []string configureOnSet bool + requireRestart bool } func (f *fakeClaudeDesktopController) UsesOllamaGateway() bool { return f.configured } @@ -402,6 +403,9 @@ func (f *fakeClaudeDesktopController) ConfigureAutodiscovery() error { } func (f *fakeClaudeDesktopController) SetInstalledFromDesktop(installed, restart bool) error { + if f.requireRestart && !restart { + return errors.New("Claude Desktop restart confirmation is required before changing its profile") + } f.installed = installed f.restart = restart f.modelsAtSet = launch.ClaudeDesktopModels() @@ -411,6 +415,28 @@ func (f *fakeClaudeDesktopController) SetInstalledFromDesktop(installed, restart return f.setErr } +func TestSetClaudeDesktopConnectionForwardsRestartConfirmation(t *testing.T) { + previousInstalled := claudeDesktopInstalled + previousDesktop := claudeDesktop + claudeDesktopInstalled = func() bool { return true } + fake := &fakeClaudeDesktopController{configured: true, requireRestart: true} + claudeDesktop = fake + t.Cleanup(func() { + claudeDesktopInstalled = previousInstalled + claudeDesktop = previousDesktop + }) + + if err := setClaudeDesktopConnection(false, false); err == nil || !strings.Contains(err.Error(), "restart confirmation is required") { + t.Fatalf("unconfirmed connection change error = %v, want restart confirmation error", err) + } + if err := setClaudeDesktopConnection(false, true); err != nil { + t.Fatalf("confirmed connection change error = %v", err) + } + if !fake.restart { + t.Fatal("confirmed restart was not forwarded to the Claude controller") + } +} + func (f *fakeClaudeDesktopController) RestartWithProfileChange(change func() error) error { if err := change(); err != nil { return err @@ -614,6 +640,88 @@ func TestClaudeDesktopIntegrationHistoryPersists(t *testing.T) { } } +func TestPrepareClaudeDesktopConnectionPreservesFirstUseIntro(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("OLLAMA_HOST", "127.0.0.1:11434") + if err := launch.SaveClaudeDesktopModels([]string{"qwen3:8b"}); err != nil { + t.Fatal(err) + } + + previousStore := appStore + previousInstalled := claudeDesktopInstalled + previousDesktop := claudeDesktop + previousAddr := claudeProxyListenAddr + previousLoader := claudeModelsLoader + previousAccess := claudeAccessStateResolver + previousLocal := claudeLocalModelsResolver + claudeProxyMu.Lock() + previousGateway := claudeAppProxy + previousAvailable := claudeAvailableModels + previousSource := claudeModelSource + previousUpdated := claudeCatalogUpdated + claudeAppProxy = nil + claudeProxyMu.Unlock() + + appStore = &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")} + claudeDesktopInstalled = func() bool { return true } + claudeDesktop = &fakeClaudeDesktopController{} + claudeProxyListenAddr = "127.0.0.1:0" + claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) { + t.Fatal("first-use preparation loaded model recommendations") + return nil, "" + } + claudeAccessStateResolver = func(context.Context) (proxy.ClaudeDesktopAccessState, error) { + t.Fatal("first-use preparation resolved cloud access") + return proxy.ClaudeDesktopAccessState{}, nil + } + localCalls := 0 + claudeLocalModelsResolver = func(context.Context) ([]string, error) { + localCalls++ + return []string{"qwen3:8b"}, nil + } + t.Cleanup(func() { + stopClaudeAppProxy() + _ = appStore.Close() + appStore = previousStore + claudeDesktopInstalled = previousInstalled + claudeDesktop = previousDesktop + claudeProxyListenAddr = previousAddr + claudeModelsLoader = previousLoader + claudeAccessStateResolver = previousAccess + claudeLocalModelsResolver = previousLocal + claudeProxyMu.Lock() + claudeAppProxy = previousGateway + claudeAvailableModels = previousAvailable + claudeModelSource = previousSource + claudeCatalogUpdated = previousUpdated + clearClaudeProxyFailure() + claudeProxyMu.Unlock() + }) + + if err := prepareClaudeDesktopConnection(); err != nil { + t.Fatal(err) + } + preparationLocalCalls := localCalls + prepared := getClaudeDesktopConnectionSummary() + if !prepared.Configured || !prepared.Connected || prepared.Used { + t.Fatalf("prepared status = %+v, want connected with first-use intro still eligible", prepared) + } + if localCalls != preparationLocalCalls { + t.Fatalf("local model lookups after status = %d, want %d", localCalls, preparationLocalCalls) + } + + if err := setClaudeDesktopConnection(true, false); err != nil { + t.Fatal(err) + } + continued := getClaudeDesktopConnectionSummary() + if !continued.Used { + t.Fatalf("continued status = %+v, want integration marked used", continued) + } + if localCalls != preparationLocalCalls { + t.Fatalf("local model lookups after Continue = %d, want %d", localCalls, preparationLocalCalls) + } +} + func TestLoadClaudeDesktopModelsFallsBackWithoutMLX(t *testing.T) { useTestOllamaRequestSigner(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -968,7 +1076,7 @@ func TestClaudeLocalModels(t *testing.T) { } } -func TestClaudeGatewayStartupWithLocalSelectionSkipsCloudLookups(t *testing.T) { +func TestClaudeGatewayStartupWithLocalSelectionSkipsCloudLookupsButSettingsLoadsCatalog(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("OLLAMA_HOST", "127.0.0.1:11434") if err := launch.SaveClaudeDesktopModels([]string{"qwen3:8b"}); err != nil { @@ -997,11 +1105,15 @@ func TestClaudeGatewayStartupWithLocalSelectionSkipsCloudLookups(t *testing.T) { accessCalls := 0 claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) { loaderCalls++ - return nil, "fallback" + return proxy.DefaultClaudeDesktopModels(), "fallback" } claudeAccessStateResolver = func(context.Context) (proxy.ClaudeDesktopAccessState, error) { accessCalls++ - return proxy.ClaudeDesktopAccessState{}, errors.New("offline") + return proxy.ClaudeDesktopAccessState{ + Cloud: proxy.ClaudeDesktopCloudOn, + Account: proxy.ClaudeDesktopAccountSignedIn, + Plan: "pro", + }, nil } claudeLocalModelsResolver = func(context.Context) ([]string, error) { return []string{"qwen3:8b"}, nil @@ -1030,11 +1142,20 @@ func TestClaudeGatewayStartupWithLocalSelectionSkipsCloudLookups(t *testing.T) { t.Fatalf("cloud startup calls = recommendations:%d access:%d, want zero", loaderCalls, accessCalls) } status := getClaudeDesktopConnectionStatus() - if len(status.Models) != 1 || status.Models[0].Name != "qwen3:8b" { - t.Fatalf("local status models = %+v", status.Models) + var foundCloud, foundSelectedLocal bool + for _, model := range status.Models { + if model.Cloud { + foundCloud = true + } + if model.Name == "qwen3:8b" && model.Selected { + foundSelectedLocal = true + } } - if loaderCalls != 0 || accessCalls != 0 { - t.Fatalf("cloud status calls = recommendations:%d access:%d, want zero", loaderCalls, accessCalls) + if !foundCloud || !foundSelectedLocal { + t.Fatalf("Settings models = %+v, want cloud choices and selected qwen3:8b", status.Models) + } + if loaderCalls != 1 || accessCalls != 1 { + t.Fatalf("Settings catalog calls = recommendations:%d access:%d, want one each", loaderCalls, accessCalls) } claudeProxyMu.Lock() models := claudeAppProxy.Models() @@ -1112,7 +1233,7 @@ func TestClaudeDesktopConnectionStatusReportsMissingApp(t *testing.T) { if status.Installed || status.Configured || status.Connected { t.Fatalf("Claude status = %+v, want missing and disconnected", status) } - if err := setClaudeDesktopConnection(true); err == nil || !strings.Contains(err.Error(), "not installed") { + if err := setClaudeDesktopConnection(true, false); err == nil || !strings.Contains(err.Error(), "not installed") { t.Fatalf("setClaudeDesktopConnection error = %v, want missing Claude error", err) } if err := prepareClaudeDesktopConnection(); err == nil || !strings.Contains(err.Error(), "not installed") { diff --git a/app/cmd/app/claude_desktop_bindings_darwin.go b/app/cmd/app/claude_desktop_bindings_darwin.go index b923bfa6a..533991752 100644 --- a/app/cmd/app/claude_desktop_bindings_darwin.go +++ b/app/cmd/app/claude_desktop_bindings_darwin.go @@ -8,11 +8,17 @@ func bindClaudeDesktop(wv webview.WebView) { wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus { return getClaudeDesktopConnectionStatus() }) + wv.Bind("getClaudeDesktopConnectionSummary", func() claudeDesktopStatus { + return getClaudeDesktopConnectionSummary() + }) + wv.Bind("getClaudeDesktopRequestCount", func() uint64 { + return claudeDesktopRequestCount() + }) - wv.Bind("setClaudeDesktopConnected", func(enabled bool) claudeDesktopActionResult { - err := setClaudeDesktopConnection(enabled) + wv.Bind("setClaudeDesktopConnected", func(enabled, restartConfirmed bool) claudeDesktopActionResult { + err := setClaudeDesktopConnection(enabled, restartConfirmed) result := claudeDesktopActionResult{ - Status: getClaudeDesktopConnectionStatus(), + Status: getClaudeDesktopConnectionSummary(), } if err != nil { result.Error = err.Error() @@ -23,7 +29,7 @@ func bindClaudeDesktop(wv webview.WebView) { wv.Bind("prepareClaudeDesktopConnection", func() claudeDesktopActionResult { err := prepareClaudeDesktopConnection() result := claudeDesktopActionResult{ - Status: getClaudeDesktopConnectionStatus(), + Status: getClaudeDesktopConnectionSummary(), } if err != nil { result.Error = err.Error() diff --git a/app/cmd/app/claude_desktop_status.go b/app/cmd/app/claude_desktop_status.go index 36b512e0c..e1ff12bb6 100644 --- a/app/cmd/app/claude_desktop_status.go +++ b/app/cmd/app/claude_desktop_status.go @@ -13,18 +13,19 @@ const ( ) type claudeDesktopStatus struct { - Supported bool `json:"supported"` - Used bool `json:"used"` - Installed bool `json:"installed"` - Configured bool `json:"configured"` - Connected bool `json:"connected"` - Running bool `json:"running"` - StartFailed bool `json:"startFailed"` - PortConflict bool `json:"portConflict"` - GatewayPort int `json:"gatewayPort,omitempty"` - Error string `json:"error,omitempty"` - ModelSource string `json:"modelSource,omitempty"` - Models []claudeDesktopModelStatus `json:"models,omitempty"` + Supported bool `json:"supported"` + Used bool `json:"used"` + Installed bool `json:"installed"` + Configured bool `json:"configured"` + Connected bool `json:"connected"` + Running bool `json:"running"` + StartFailed bool `json:"startFailed"` + PortConflict bool `json:"portConflict"` + GatewayPort int `json:"gatewayPort,omitempty"` + RoutedRequests uint64 `json:"routedRequests"` + Error string `json:"error,omitempty"` + ModelSource string `json:"modelSource,omitempty"` + Models []claudeDesktopModelStatus `json:"models,omitempty"` } type claudeDesktopModelStatus struct { diff --git a/app/ui/app/package-lock.json b/app/ui/app/package-lock.json index 0dcd91a20..bf441f388 100644 --- a/app/ui/app/package-lock.json +++ b/app/ui/app/package-lock.json @@ -43,6 +43,7 @@ "@types/node": "^24.7.2", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", + "@types/react-test-renderer": "^19.1.0", "@vitejs/plugin-react": "^4.4.1", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3.2.4", @@ -56,6 +57,7 @@ "playwright": "^1.53.2", "postcss-preset-env": "^10.2.4", "react-markdown": "^10.1.0", + "react-test-renderer": "19.1.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-stringify": "^11.0.0", @@ -4593,6 +4595,16 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", @@ -11152,6 +11164,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^19.1.0", + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-test-renderer/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", diff --git a/app/ui/app/package.json b/app/ui/app/package.json index 5532e70f0..89a205b5a 100644 --- a/app/ui/app/package.json +++ b/app/ui/app/package.json @@ -52,6 +52,7 @@ "@types/node": "^24.7.2", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", + "@types/react-test-renderer": "^19.1.0", "@vitejs/plugin-react": "^4.4.1", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3.2.4", @@ -65,6 +66,7 @@ "playwright": "^1.53.2", "postcss-preset-env": "^10.2.4", "react-markdown": "^10.1.0", + "react-test-renderer": "19.1.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-stringify": "^11.0.0", diff --git a/app/ui/app/src/components/Onboarding.test.tsx b/app/ui/app/src/components/Onboarding.test.tsx index a35d9ebd4..e50179abd 100644 --- a/app/ui/app/src/components/Onboarding.test.tsx +++ b/app/ui/app/src/components/Onboarding.test.tsx @@ -1,4 +1,5 @@ import { renderToStaticMarkup } from "react-dom/server"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; import { describe, expect, it, vi } from "vitest"; import { ClaudeConnectedIntro, @@ -12,6 +13,7 @@ import { WelcomeScreen, } from "./Onboarding"; import { + CLAUDE_CONNECTION_TIMEOUT_MS, CLAUDE_INSTALL_TIMEOUT_MS, isClaudeConnectionComplete, scheduleClaudeInstallTimeout, @@ -115,7 +117,7 @@ describe("Onboarding", () => { expect(authenticationTimeoutAction(true, true)).toBe("ignore"); }); - it("finishes Claude connection states from the native status hook", () => { + it("detects when the menu bar already reached the requested Claude state", () => { const status = { supported: true, installed: true, @@ -162,6 +164,131 @@ describe("Onboarding", () => { } }); + it("preserves a late native error after the Connect Apps action times out", async () => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + + const disconnectedStatus = { + supported: true, + used: false, + installed: true, + configured: false, + connected: false, + running: false, + startFailed: false, + portConflict: false, + }; + const connectedStatus = { + ...disconnectedStatus, + configured: true, + connected: true, + }; + let finishNativeAction!: (result: { + status: typeof connectedStatus; + error?: string; + }) => void; + const nativeAction = new Promise<{ + status: typeof connectedStatus; + error?: string; + }>((resolve) => { + finishNativeAction = resolve; + }); + const getClaudeStatus = vi + .fn() + .mockResolvedValueOnce(disconnectedStatus) + .mockResolvedValue(connectedStatus); + const setClaudeConnected = vi.fn().mockReturnValue(nativeAction); + vi.stubGlobal("navigator", { platform: "MacIntel" }); + vi.stubGlobal("window", { + OLLAMA_PLATFORM: "darwin", + innerHeight: 660, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + getClaudeDesktopConnectionSummary: getClaudeStatus, + setClaudeDesktopConnected: setClaudeConnected, + }); + + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + , + ); + await Promise.resolve(); + }); + + const claudeSwitch = () => renderer!.root.findByProps({ role: "switch" }); + expect(claudeSwitch().props["aria-checked"]).toBe(false); + expect(claudeSwitch().props["aria-busy"]).toBeUndefined(); + expect(claudeSwitch().props.disabled).toBe(false); + + let clickResult!: Promise; + await act(async () => { + clickResult = claudeSwitch().props.onClick(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(setClaudeConnected).toHaveBeenCalledWith(true, false); + expect(claudeSwitch().props["aria-checked"]).toBe(true); + expect(claudeSwitch().props["aria-busy"]).toBe(true); + expect(claudeSwitch().props.disabled).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(CLAUDE_CONNECTION_TIMEOUT_MS); + await clickResult; + }); + + expect(claudeSwitch().props["aria-checked"]).toBe(false); + expect(claudeSwitch().props["aria-busy"]).toBeUndefined(); + expect(claudeSwitch().props.disabled).toBe(false); + expect( + renderer.root.findByProps({ role: "alert" }).children.join(""), + ).toContain("Claude is taking too long to connect"); + + await act(async () => { + finishNativeAction({ + status: connectedStatus, + error: "Claude failed to restart.", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getClaudeStatus).toHaveBeenCalledOnce(); + expect(claudeSwitch().props["aria-checked"]).toBe(true); + expect(claudeSwitch().props["aria-busy"]).toBeUndefined(); + expect(claudeSwitch().props.disabled).toBe(false); + expect( + renderer.root.findByProps({ role: "alert" }).children.join(""), + ).toContain("Claude failed to restart."); + expect( + renderer.root.findAllByProps({ id: "claude-connected-title" }), + ).toHaveLength(0); + } finally { + if (renderer) { + act(() => renderer?.unmount()); + } + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + it("shows the Claude intro only before the integration has been used", () => { const firstConnection = { supported: true, @@ -366,6 +493,7 @@ describe("Onboarding", () => { running: false, startFailed: false, portConflict: false, + routedRequests: 12, }} initialIntegrations={[ { @@ -394,6 +522,7 @@ describe("Onboarding", () => { expect(html).not.toContain("Inactive"); expect(html).toContain('aria-checked="true"'); expect(html).toContain('aria-label="Disconnect Claude"'); + expect(html).toContain("Connected to Ollama · 12 requests this session"); }); it("shows initial Claude recovery guidance without error styling", () => { diff --git a/app/ui/app/src/components/Onboarding.tsx b/app/ui/app/src/components/Onboarding.tsx index 6f21c329b..4544d5c4f 100644 --- a/app/ui/app/src/components/Onboarding.tsx +++ b/app/ui/app/src/components/Onboarding.tsx @@ -8,13 +8,21 @@ import { } from "@/api"; import { INTEGRATION_ICONS } from "@/lib/launchCommands"; import { + CLAUDE_CONNECTION_TIMEOUT_MS, + ClaudeConnectionTimeoutError, claudeDesktopRecoveryMessage, + claudeDesktopRequestCountLabel, isClaudeConfigured, isClaudeConnectionComplete, + optimisticClaudeConnectionState, scheduleClaudeInstallTimeout, + withClaudeConnectionTimeout, } from "@/lib/claudeDesktop"; import { isWindowsPlatform } from "@/lib/platform"; -import type { ClaudeDesktopStatus } from "@/types/webview"; +import type { + ClaudeDesktopActionResult, + ClaudeDesktopStatus, +} from "@/types/webview"; import { copyTextToClipboard } from "@/utils/clipboard"; import { ArrowPathIcon, @@ -46,8 +54,6 @@ type ClaudeConnectPhase = | "launching" | "disconnecting"; -const CLAUDE_CONNECTION_POLL_INTERVAL_MS = 500; -const CLAUDE_CONNECTION_TIMEOUT_MS = 45_000; const MINIMUM_APP_WINDOW_HEIGHT = 660; const TERMINAL_ROW_HEIGHT_WITH_GAP = 80; const TERMINAL_LIST_RESERVED_HEIGHT = 296; @@ -56,14 +62,24 @@ export function shouldShowClaudeConnectedIntro(status: ClaudeDesktopStatus) { return status.connected && !status.startFailed && !status.used; } -function setClaudeConnection(enabled: boolean, deferLaunch = false) { +function setClaudeConnection( + enabled: boolean, + deferLaunch = false, + restartConfirmed = false, +) { if (enabled && deferLaunch && window.prepareClaudeDesktopConnection) { return window.prepareClaudeDesktopConnection(); } if (!window.setClaudeDesktopConnected) { throw new Error("Claude Desktop connection is unavailable"); } - return window.setClaudeDesktopConnected(enabled); + return window.setClaudeDesktopConnected(enabled, restartConfirmed); +} + +function getClaudeConnectionSummary() { + const getStatus = + window.getClaudeDesktopConnectionSummary ?? window.getClaudeDesktopStatus; + return getStatus?.() ?? Promise.resolve(null); } export function terminalRowsForWindowHeight(height: number): number { @@ -435,6 +451,8 @@ export function ConnectAppsScreen({ const [showClaudeConnectedIntro, setShowClaudeConnectedIntro] = useState(false); const claudeConnectedIntroPending = useRef(false); + const claudeRestartConfirmed = useRef(false); + const screenMounted = useRef(true); const [integrationStatuses, setIntegrationStatuses] = useState(initialIntegrations ?? null); const [showAllIntegrations, setShowAllIntegrations] = useState(false); @@ -448,6 +466,13 @@ export function ConnectAppsScreen({ ); const [statusError, setStatusError] = useState(false); + useEffect(() => { + screenMounted.current = true; + return () => { + screenMounted.current = false; + }; + }, []); + useEffect(() => { const updateCollapsedIntegrationCount = () => { setCollapsedIntegrationCount( @@ -473,14 +498,22 @@ export function ConnectAppsScreen({ const refreshClaudeStatus = useCallback(async () => { if (isWindows) return null; - if (!window.getClaudeDesktopStatus) return null; + if ( + !window.getClaudeDesktopConnectionSummary && + !window.getClaudeDesktopStatus + ) { + return null; + } try { - const status = await window.getClaudeDesktopStatus(); + const status = await getClaudeConnectionSummary(); + if (!status || !screenMounted.current) return null; setClaudeStatus(status); setClaudeError(null); return status; } catch { - setClaudeError("Ollama could not read the Claude connection status."); + if (screenMounted.current) { + setClaudeError("Ollama could not read the Claude connection status."); + } return null; } }, [isWindows]); @@ -492,9 +525,7 @@ export function ConnectAppsScreen({ : getIntegrationStatuses(); const claude = initialClaudeStatus ? Promise.resolve(initialClaudeStatus) - : window.getClaudeDesktopStatus - ? window.getClaudeDesktopStatus() - : Promise.resolve(null); + : getClaudeConnectionSummary(); void Promise.allSettled([integrations, claude]).then( ([integrationResult, claudeResult]) => { @@ -546,17 +577,86 @@ export function ConnectAppsScreen({ [openConnectedClaude], ); + const reconcileLateClaudeAction = useCallback( + (enabled: boolean) => + (settled: PromiseSettledResult) => { + if (!screenMounted.current) return; + + if (settled.status === "fulfilled") { + const result = settled.value; + setClaudeStatus(result.status); + setClaudeError(result.error || null); + if (result.error || !enabled) return; + + void finishClaudeConnection(result.status).then( + (completionError) => { + if (completionError && screenMounted.current) { + setClaudeError(completionError); + } + }, + () => { + if (screenMounted.current) { + setClaudeError( + "Ollama connected Claude, but could not open the app.", + ); + } + }, + ); + return; + } + + void refreshClaudeStatus().then(() => { + if (screenMounted.current) { + setClaudeError( + enabled + ? "Ollama could not connect to Claude." + : "Ollama could not disconnect from Claude.", + ); + } + }); + }, + [finishClaudeConnection, refreshClaudeStatus], + ); + const dismissClaudeConnectedIntro = async () => { - claudeConnectedIntroPending.current = false; - setShowClaudeConnectedIntro(false); if (!window.setClaudeDesktopConnected) return; setClaudePhase("launching"); try { - const result = await window.setClaudeDesktopConnected(true); + const liveStatus = await withClaudeConnectionTimeout( + getClaudeConnectionSummary(), + ); + if (!liveStatus) { + throw new Error("Claude Desktop connection status is unavailable"); + } + setClaudeStatus(liveStatus); + + let restartConfirmed = claudeRestartConfirmed.current; + if (liveStatus.running && !restartConfirmed) { + restartConfirmed = window.confirm( + "Restart Claude Desktop to use Ollama? Any running task will stop.", + ); + if (!restartConfirmed) { + setClaudePhase("idle"); + return; + } + } + + claudeConnectedIntroPending.current = false; + claudeRestartConfirmed.current = false; + setShowClaudeConnectedIntro(false); + const result = await withClaudeConnectionTimeout( + window.setClaudeDesktopConnected(true, restartConfirmed), + CLAUDE_CONNECTION_TIMEOUT_MS, + reconcileLateClaudeAction(true), + ); setClaudeStatus(result.status); setClaudeError(result.error || null); - } catch { - setClaudeError("Ollama connected Claude, but could not open the app."); + } catch (error) { + setClaudeError( + error instanceof ClaudeConnectionTimeoutError + ? "Claude is taking too long to launch. Check Claude and try again." + : "Ollama connected Claude, but could not open the app.", + ); } finally { setClaudePhase("idle"); } @@ -569,70 +669,60 @@ export function ConnectAppsScreen({ }, [refreshClaudeStatus]); useEffect(() => { - if (claudePhase !== "connecting" && claudePhase !== "disconnecting") { + if (!claudeStatus?.connected || !window.getClaudeDesktopRequestCount) { return; } - if (!window.getClaudeDesktopStatus) return; - const enabling = claudePhase === "connecting"; let active = true; let checking = false; - - const checkConnection = async () => { - if (!active || checking || !window.getClaudeDesktopStatus) return; + const refreshRequestCount = async () => { + if (!active || checking || document.visibilityState === "hidden") return; checking = true; try { - const status = await window.getClaudeDesktopStatus(); - if (!active) return; - setClaudeStatus(status); - if (!isClaudeConnectionComplete(enabling, status)) return; - - const openError = enabling - ? await finishClaudeConnection(status) - : null; - if (!active) return; - setClaudeError(openError); - setClaudePhase("idle"); + const routedRequests = await window.getClaudeDesktopRequestCount?.(); + if (!active || routedRequests === undefined) return; + setClaudeStatus((current) => { + if (!current || current.routedRequests === routedRequests) { + return current; + } + return { ...current, routedRequests }; + }); } catch { - // Keep polling until the native action completes or the timeout fires. + // The next interval or window-focus refresh can recover the count. } finally { checking = false; } }; - void checkConnection(); - const interval = window.setInterval( - checkConnection, - CLAUDE_CONNECTION_POLL_INTERVAL_MS, - ); - const timeout = window.setTimeout(() => { - if (!active) return; - setClaudeError( - enabling - ? "Claude is taking too long to connect. Check Claude and try again." - : "Claude is taking too long to disconnect. Try again.", - ); - setClaudePhase("idle"); - }, CLAUDE_CONNECTION_TIMEOUT_MS); - + void refreshRequestCount(); + const interval = window.setInterval(refreshRequestCount, 1000); return () => { active = false; window.clearInterval(interval); - window.clearTimeout(timeout); }; - }, [claudePhase, finishClaudeConnection]); + }, [claudeStatus?.connected]); useEffect(() => { if (claudePhase !== "waiting-for-install") return; let active = true; let completing = false; + let checking = false; const checkForInstall = async () => { - if (!window.getClaudeDesktopStatus || !window.setClaudeDesktopConnected) { + if (checking) return; + if ( + (!window.getClaudeDesktopConnectionSummary && + !window.getClaudeDesktopStatus) || + !window.setClaudeDesktopConnected + ) { return; } + checking = true; try { - const status = await window.getClaudeDesktopStatus(); + const status = await withClaudeConnectionTimeout( + getClaudeConnectionSummary(), + ); + if (!status) return; if (!active) return; setClaudeStatus(status); if (!status.installed || completing) return; @@ -647,8 +737,13 @@ export function ConnectAppsScreen({ } setClaudePhase("connecting"); - const result = await setClaudeConnection(true, !status.used); - if (!active) return; + claudeRestartConfirmed.current = false; + const result = await withClaudeConnectionTimeout( + setClaudeConnection(true, !status.used, false), + CLAUDE_CONNECTION_TIMEOUT_MS, + reconcileLateClaudeAction(true), + ); + if (!screenMounted.current) return; setClaudeStatus(result.status); let actionError = result.error || null; if (!actionError && result.status.connected) { @@ -658,10 +753,16 @@ export function ConnectAppsScreen({ } setClaudeError(actionError); setClaudePhase("idle"); - } catch { - if (!active) return; + } catch (error) { + if (!screenMounted.current) return; setClaudePhase("idle"); - setClaudeError("Ollama could not finish connecting Claude."); + setClaudeError( + error instanceof ClaudeConnectionTimeoutError + ? "Claude is taking too long to connect. Check Claude and try again." + : "Ollama could not finish connecting Claude.", + ); + } finally { + checking = false; } }; @@ -677,7 +778,7 @@ export function ConnectAppsScreen({ window.clearInterval(interval); window.clearTimeout(timeout); }; - }, [claudePhase, finishClaudeConnection]); + }, [claudePhase, finishClaudeConnection, reconcileLateClaudeAction]); const copyLaunchCommand = async (item: IntegrationStatus) => { if (item.command && (await copyTextToClipboard(item.command))) { @@ -688,18 +789,55 @@ export function ConnectAppsScreen({ const connectClaude = async () => { if (claudePhase !== "idle") return; - if (!window.getClaudeDesktopStatus || !window.setClaudeDesktopConnected) { + if ( + (!window.getClaudeDesktopConnectionSummary && + !window.getClaudeDesktopStatus) || + !window.setClaudeDesktopConnected + ) { setClaudeError("Claude connection is available in the Ollama macOS app."); return; } setCopiedCommand(null); setClaudeError(null); - const status = await refreshClaudeStatus(); - if (!status) return; - const enabling = !isClaudeConfigured(status); + const enabling = claudeStatus ? !isClaudeConfigured(claudeStatus) : true; + setClaudePhase(enabling ? "connecting" : "disconnecting"); + + let status: ClaudeDesktopStatus | null; + try { + status = await withClaudeConnectionTimeout(getClaudeConnectionSummary()); + } catch (error) { + setClaudePhase("idle"); + setClaudeError( + error instanceof ClaudeConnectionTimeoutError + ? `Claude is taking too long to ${enabling ? "connect" : "disconnect"}. Try again.` + : "Ollama could not read the Claude connection status.", + ); + return; + } + if (!status) { + setClaudePhase("idle"); + setClaudeError("Ollama could not read the Claude connection status."); + return; + } + setClaudeStatus(status); + + // The menu-bar control may have reached this target since the app last + // refreshed. Sync the row without repeating the profile change or + // restarting Claude, while preserving first-use completion behavior. + if (isClaudeConnectionComplete(enabling, status)) { + claudeRestartConfirmed.current = false; + const completionError = enabling + ? await finishClaudeConnection(status) + : null; + setClaudeError(completionError); + setClaudePhase("idle"); + return; + } + if (enabling && !status.installed) { if (!window.installClaudeDesktop) { + setClaudePhase("idle"); setClaudeError("Ollama could not open the Claude installer."); return; } @@ -723,20 +861,29 @@ export function ConnectAppsScreen({ return; } + let restartConfirmed = false; if (status.running) { - const confirmed = window.confirm( + restartConfirmed = window.confirm( enabling ? "Restart Claude Desktop to use Ollama? Any running task will stop." : "Restart Claude Desktop to remove Ollama? Any running task will stop.", ); - if (!confirmed) return; + if (!restartConfirmed) { + setClaudePhase("idle"); + return; + } } - setClaudePhase(enabling ? "connecting" : "disconnecting"); + claudeRestartConfirmed.current = restartConfirmed; try { - const result = await setClaudeConnection( - enabling, - enabling && !status.used, + const result = await withClaudeConnectionTimeout( + setClaudeConnection( + enabling, + enabling && !status.used, + restartConfirmed, + ), + CLAUDE_CONNECTION_TIMEOUT_MS, + reconcileLateClaudeAction(enabling), ); setClaudeStatus(result.status); let actionError = result.error || null; @@ -752,13 +899,18 @@ export function ConnectAppsScreen({ : "Ollama could not disconnect from Claude."; } setClaudeError(actionError); - } catch { + } catch (error) { setClaudeError( - enabling - ? "Ollama could not connect to Claude." - : "Ollama could not disconnect from Claude.", + error instanceof ClaudeConnectionTimeoutError + ? `Claude is taking too long to ${enabling ? "connect" : "disconnect"}. Try again.` + : enabling + ? "Ollama could not connect to Claude." + : "Ollama could not disconnect from Claude.", ); } finally { + if (!claudeConnectedIntroPending.current) { + claudeRestartConfirmed.current = false; + } setClaudePhase("idle"); } }; @@ -774,6 +926,16 @@ export function ConnectAppsScreen({ const claudeConfigured = claudeStatus ? isClaudeConfigured(claudeStatus) : false; + const pendingClaudeConnection = + claudePhase === "connecting" || claudePhase === "launching" + ? true + : claudePhase === "disconnecting" + ? false + : null; + const claudeToggleConfigured = optimisticClaudeConnectionState( + claudeConfigured, + pendingClaudeConnection, + ); const claudeInstalled = claudeStatus?.installed ?? claudeIntegration?.installed ?? false; const isConnectingClaude = claudePhase !== "idle"; @@ -859,7 +1021,7 @@ export function ConnectAppsScreen({ > {claudeGuidance ?? (claudeConnected - ? "Connected to Ollama" + ? `Connected to Ollama · ${claudeDesktopRequestCountLabel(claudeStatus?.routedRequests ?? 0)}` : claudePhase === "installing" ? "Ollama is downloading the Claude installer…" : claudePhase === "waiting-for-install" @@ -890,7 +1052,7 @@ export function ConnectAppsScreen({ diff --git a/app/ui/app/src/lib/claudeDesktop.test.ts b/app/ui/app/src/lib/claudeDesktop.test.ts index cc072532e..64a303515 100644 --- a/app/ui/app/src/lib/claudeDesktop.test.ts +++ b/app/ui/app/src/lib/claudeDesktop.test.ts @@ -1,12 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { addClaudeModelSelection, + ClaudeConnectionTimeoutError, claudeDesktopRecoveryMessage, claudeDesktopMaxModels, claudeDesktopMaxModelsMessage, + claudeDesktopRequestCountLabel, claudeDesktopUsableSelection, defaultClaudeDesktopMaxModels, isClaudeConfigured, + optimisticClaudeConnectionState, + withClaudeConnectionTimeout, } from "./claudeDesktop"; describe("isClaudeConfigured", () => { @@ -26,6 +30,68 @@ describe("isClaudeConfigured", () => { }); }); +describe("optimisticClaudeConnectionState", () => { + it("shows the requested state while a connection change is pending", () => { + expect(optimisticClaudeConnectionState(false, true)).toBe(true); + expect(optimisticClaudeConnectionState(true, false)).toBe(false); + }); + + it("uses the confirmed state when no change is pending", () => { + expect(optimisticClaudeConnectionState(true, null)).toBe(true); + expect(optimisticClaudeConnectionState(false, null)).toBe(false); + }); +}); + +describe("claudeDesktopRequestCountLabel", () => { + it("formats zero, singular, and plural session counts", () => { + expect(claudeDesktopRequestCountLabel(0)).toBe("0 requests this session"); + expect(claudeDesktopRequestCountLabel(1)).toBe("1 request this session"); + expect(claudeDesktopRequestCountLabel(2)).toBe("2 requests this session"); + }); +}); + +describe("withClaudeConnectionTimeout", () => { + it("returns a native result that finishes before the watchdog", async () => { + await expect( + withClaudeConnectionTimeout(Promise.resolve("connected"), 100), + ).resolves.toBe("connected"); + }); + + it("reports when native work settles after the watchdog", async () => { + vi.useFakeTimers(); + try { + let resolveNative!: (value: string) => void; + const nativeAction = new Promise((resolve) => { + resolveNative = resolve; + }); + const onLateSettled = vi.fn(); + const result = withClaudeConnectionTimeout( + nativeAction, + 100, + onLateSettled, + ); + const timedOut = expect(result).rejects.toBeInstanceOf( + ClaudeConnectionTimeoutError, + ); + + await vi.advanceTimersByTimeAsync(100); + await timedOut; + expect(onLateSettled).not.toHaveBeenCalled(); + resolveNative("late connection"); + await Promise.resolve(); + + expect(onLateSettled).toHaveBeenCalledOnce(); + expect(onLateSettled).toHaveBeenCalledWith({ + status: "fulfilled", + value: "late connection", + }); + await expect(result).rejects.toBeInstanceOf(ClaudeConnectionTimeoutError); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("claudeDesktopMaxModels", () => { it("falls back to the five literal Claude slots without a status", () => { expect(claudeDesktopMaxModels(undefined)).toBe(5); diff --git a/app/ui/app/src/lib/claudeDesktop.ts b/app/ui/app/src/lib/claudeDesktop.ts index 13f1f88e4..aae926626 100644 --- a/app/ui/app/src/lib/claudeDesktop.ts +++ b/app/ui/app/src/lib/claudeDesktop.ts @@ -4,6 +4,46 @@ import type { } from "@/types/webview"; export const CLAUDE_INSTALL_TIMEOUT_MS = 120_000; +export const CLAUDE_CONNECTION_TIMEOUT_MS = 45_000; + +export class ClaudeConnectionTimeoutError extends Error { + constructor() { + super("Claude connection timed out"); + this.name = "ClaudeConnectionTimeoutError"; + } +} + +export function withClaudeConnectionTimeout( + action: Promise, + timeoutMs = CLAUDE_CONNECTION_TIMEOUT_MS, + onLateSettled?: (result: PromiseSettledResult) => void, +): Promise { + return new Promise((resolve, reject) => { + let timedOut = false; + const timeout = globalThis.setTimeout(() => { + timedOut = true; + reject(new ClaudeConnectionTimeoutError()); + }, timeoutMs); + action.then( + (value) => { + globalThis.clearTimeout(timeout); + if (timedOut) { + onLateSettled?.({ status: "fulfilled", value }); + return; + } + resolve(value); + }, + (error: unknown) => { + globalThis.clearTimeout(timeout); + if (timedOut) { + onLateSettled?.({ status: "rejected", reason: error }); + return; + } + reject(error); + }, + ); + }); +} export function isClaudeConnectionComplete( enabled: boolean, @@ -19,6 +59,17 @@ export function isClaudeConfigured(status: ClaudeDesktopStatus): boolean { return status.configured ?? status.connected; } +export function optimisticClaudeConnectionState( + configured: boolean, + pending: boolean | null, +): boolean { + return pending ?? configured; +} + +export function claudeDesktopRequestCountLabel(count: number): string { + return `${count} ${count === 1 ? "request" : "requests"} this session`; +} + export function claudeDesktopRecoveryMessage( statusError?: string, actionError?: string | null, diff --git a/app/ui/app/src/types/webview.d.ts b/app/ui/app/src/types/webview.d.ts index ae8e9c043..b7cb4533d 100644 --- a/app/ui/app/src/types/webview.d.ts +++ b/app/ui/app/src/types/webview.d.ts @@ -22,6 +22,7 @@ interface ClaudeDesktopStatus { startFailed: boolean; portConflict: boolean; gatewayPort?: number; + routedRequests?: number; error?: string; modelSource?: "user" | "endpoint" | "fallback"; maxModels?: number; @@ -65,8 +66,11 @@ declare global { doubleClick?: () => void; activateOllama?: () => void; getClaudeDesktopStatus?: () => Promise; + getClaudeDesktopConnectionSummary?: () => Promise; + getClaudeDesktopRequestCount?: () => Promise; setClaudeDesktopConnected?: ( enabled: boolean, + restartConfirmed: boolean, ) => Promise; prepareClaudeDesktopConnection?: () => Promise; openClaudeDesktop?: () => Promise; diff --git a/app/ui/ui.go b/app/ui/ui.go index 4b4a031d7..e5458f3a3 100644 --- a/app/ui/ui.go +++ b/app/ui/ui.go @@ -350,7 +350,7 @@ func (s *Server) getIntegrationStatuses(w http.ResponseWriter, _ *http.Request) byName[info.Name] = info } seen := map[string]bool{"chatgpt": true} - launcherMenuOrder := []string{"claude", "codex", "openclaw", "opencode", "droid", "pi", "cline"} + launcherMenuOrder := []string{"claude", "codex", "openclaw", "opencode", "hermes", "hermes-desktop", "droid", "pi", "cline"} orderedInfos := make([]launch.IntegrationInfo, 0, len(infos)) for _, name := range launcherMenuOrder { if info, ok := byName[name]; ok { diff --git a/app/ui/ui_test.go b/app/ui/ui_test.go index 64d7caf30..a63bd608f 100644 --- a/app/ui/ui_test.go +++ b/app/ui/ui_test.go @@ -149,7 +149,7 @@ func TestGetIntegrationStatuses(t *testing.T) { if got[0].ID != "claude-desktop" || got[0].Action != "connect" || got[0].Command != "" { t.Fatalf("first integration = %+v, want command-free Claude Desktop connect", got[0]) } - wantPrefix := []string{"claude-desktop", "claude", "codex", "openclaw", "opencode", "droid", "pi", "cline"} + wantPrefix := []string{"claude-desktop", "claude", "codex", "openclaw", "opencode", "hermes", "hermes-desktop", "droid", "pi", "cline"} for i, want := range wantPrefix { if got[i].ID != want { t.Fatalf("integration %d = %q, want launcher menu order entry %q", i, got[i].ID, want)