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)