app: improve desktop integration responsiveness (#17973)

* app: improve desktop integration responsiveness

* app: reconcile delayed Claude connection results

* app: preserve delayed Claude action errors
This commit is contained in:
Eva H 2026-08-24 19:25:24 -04:00 • committed by GitHub
parent 939425152e
commit e2e82903fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 738 additions and 140 deletions

View file

@ -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 {

View file

@ -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") {

View file

@ -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()

View file

@ -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 {

View file

@ -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",

View file

@ -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",

View file

@ -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(
<ConnectAppsScreen
initialClaudeStatus={disconnectedStatus}
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
action: "connect",
},
]}
/>,
);
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<void>;
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", () => {

View file

@ -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<IntegrationStatuses | null>(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<ClaudeDesktopActionResult>) => {
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({
<button
type="button"
role="switch"
aria-checked={claudeConfigured}
aria-checked={claudeToggleConfigured}
aria-busy={isConnectingClaude || undefined}
aria-label={
claudeConfigured
@ -902,11 +1064,11 @@ export function ConnectAppsScreen({
title={claudeConfigured ? "Disconnect" : "Connect"}
disabled={isConnectingClaude}
onClick={connectClaude}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait ${claudeConfigured ? "bg-neutral-950" : "bg-neutral-300"}`}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait ${claudeToggleConfigured ? "bg-neutral-950" : "bg-neutral-300"}`}
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${isConnectingClaude ? "animate-pulse" : ""} ${claudeConfigured ? "translate-x-4.5" : "translate-x-0.5"}`}
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${isConnectingClaude ? "animate-pulse" : ""} ${claudeToggleConfigured ? "translate-x-4.5" : "translate-x-0.5"}`}
/>
</button>
</div>

View file

@ -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<string>((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);

View file

@ -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<T>(
action: Promise<T>,
timeoutMs = CLAUDE_CONNECTION_TIMEOUT_MS,
onLateSettled?: (result: PromiseSettledResult<T>) => void,
): Promise<T> {
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,

View file

@ -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<ClaudeDesktopStatus>;
getClaudeDesktopConnectionSummary?: () => Promise<ClaudeDesktopStatus>;
getClaudeDesktopRequestCount?: () => Promise<number>;
setClaudeDesktopConnected?: (
enabled: boolean,
restartConfirmed: boolean,
) => Promise<ClaudeDesktopActionResult>;
prepareClaudeDesktopConnection?: () => Promise<ClaudeDesktopActionResult>;
openClaudeDesktop?: () => Promise<string>;

View file

@ -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 {

View file

@ -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)