mirror of
https://github.com/ollama/ollama.git
synced 2026-10-21 19:11:17 +00:00
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:
parent
939425152e
commit
e2e82903fa
13 changed files with 738 additions and 140 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
33
app/ui/app/package-lock.json
generated
33
app/ui/app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
4
app/ui/app/src/types/webview.d.ts
vendored
4
app/ui/app/src/types/webview.d.ts
vendored
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue