mirror of
https://github.com/ollama/ollama.git
synced 2026-10-21 11:01:33 +00:00
app: defer Settings model discovery (#18598)
This commit is contained in:
parent
9eae4fac93
commit
8a5d443148
28 changed files with 1674 additions and 202 deletions
|
|
@ -266,11 +266,12 @@ func main() {
|
|||
done <- osrv.Run(octx)
|
||||
}()
|
||||
},
|
||||
Store: st,
|
||||
ToolRegistry: toolRegistry,
|
||||
Dev: devMode,
|
||||
Logger: slog.Default(),
|
||||
Updater: upd,
|
||||
Store: st,
|
||||
IntegrationModels: desktopModelSettingsHandler(),
|
||||
ToolRegistry: toolRegistry,
|
||||
Dev: devMode,
|
||||
Logger: slog.Default(),
|
||||
Updater: upd,
|
||||
UpdateAvailableFunc: func() {
|
||||
UpdateAvailable("")
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1355,6 +1355,10 @@ func claudeDesktopConnectionSummary(used bool) claudeDesktopStatus {
|
|||
}
|
||||
|
||||
func getClaudeDesktopConnectionStatus() claudeDesktopStatus {
|
||||
return claudeDesktopConnectionStatus(context.Background())
|
||||
}
|
||||
|
||||
func claudeDesktopConnectionStatus(ctx context.Context) claudeDesktopStatus {
|
||||
used := hasUsedClaudeDesktopIntegration()
|
||||
var availableModels, selectedModels []proxy.ClaudeDesktopModel
|
||||
var modelSource string
|
||||
|
|
@ -1368,7 +1372,7 @@ func getClaudeDesktopConnectionStatus() claudeDesktopStatus {
|
|||
cachedCatalogHasCloud := hasCloudClaudeDesktopModel(claudeAvailableModels)
|
||||
claudeProxyMu.Unlock()
|
||||
var accessErr error
|
||||
accessState, accessErr = claudeAccessStateResolver(context.Background())
|
||||
accessState, accessErr = claudeAccessStateResolver(ctx)
|
||||
if accessErr != nil {
|
||||
slog.Debug("could not resolve Claude model access for Settings", "error", accessErr)
|
||||
}
|
||||
|
|
@ -1379,7 +1383,7 @@ func getClaudeDesktopConnectionStatus() claudeDesktopStatus {
|
|||
if accessErr == nil && accessState.Cloud == proxy.ClaudeDesktopCloudOn {
|
||||
// Local-only startup deliberately seeds the cache with only the active
|
||||
// routes. Settings still needs the recommendation catalog when Cloud is on.
|
||||
availableModels, selectedModels, modelSource = refreshClaudeDesktopCatalog(context.Background(), current, !cachedCatalogHasCloud)
|
||||
availableModels, selectedModels, modelSource = refreshClaudeDesktopCatalog(ctx, current, !cachedCatalogHasCloud)
|
||||
} else {
|
||||
selectedModels = current
|
||||
if len(selectedModels) == 0 {
|
||||
|
|
@ -1398,7 +1402,7 @@ func getClaudeDesktopConnectionStatus() claudeDesktopStatus {
|
|||
var localNames []string
|
||||
var localErr error
|
||||
if len(availableModels) > 0 {
|
||||
localNames, localErr = claudeLocalModelsResolver(context.Background())
|
||||
localNames, localErr = claudeLocalModelsResolver(ctx)
|
||||
if localErr != nil {
|
||||
slog.Debug("could not load local models for Claude Settings", "error", localErr)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ import (
|
|||
)
|
||||
|
||||
func bindClaudeDesktop(wv webview.WebView) {
|
||||
wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus {
|
||||
return getClaudeDesktopConnectionStatus()
|
||||
})
|
||||
wv.Bind("getClaudeDesktopConnectionSummary", func() claudeDesktopStatus {
|
||||
return getClaudeDesktopConnectionSummary()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,15 +43,6 @@ func bindCodexDesktop(wv webview.WebView) {
|
|||
wv.Bind("installCodexDesktop", func() codexDesktopInstallResult {
|
||||
return requestCodexDesktopInstall()
|
||||
})
|
||||
wv.Bind("getCodexDesktopModelsSettings", func() codexDesktopModelsSettingsResult {
|
||||
settings, err := getCodexDesktopModelsSettings()
|
||||
result := codexDesktopModelsSettingsResult{Settings: settings}
|
||||
if err != nil {
|
||||
result.Warning = codexDesktopModelRefreshError(settings)
|
||||
slog.Warn("failed to refresh available ChatGPT models", "error", err)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("applyCodexDesktopModels", func(models []string, restartConfirmed bool) codexDesktopModelsSettingsResult {
|
||||
err := applyCodexDesktopModels(models, restartConfirmed)
|
||||
settings, statusErr := getCodexDesktopModelsSettings()
|
||||
|
|
|
|||
|
|
@ -214,6 +214,12 @@ func setCodexDesktopConnection(enabled, restartConfirmed bool) error {
|
|||
}
|
||||
|
||||
func getCodexDesktopModelsSettings() (codexDesktopModelsSettings, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return codexDesktopSettingsWithInventory(ctx, codexDesktopSettingsSummary())
|
||||
}
|
||||
|
||||
func codexDesktopSettingsSummary() codexDesktopModelsSettings {
|
||||
settings := codexDesktopModelsSettings{
|
||||
Supported: true,
|
||||
Installed: codexDesktop.Installed(),
|
||||
|
|
@ -230,8 +236,10 @@ func getCodexDesktopModelsSettings() (codexDesktopModelsSettings, error) {
|
|||
if len(settings.Selected) > codexDesktopMaxModels {
|
||||
settings.Selected = settings.Selected[:codexDesktopMaxModels]
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return settings
|
||||
}
|
||||
|
||||
func codexDesktopSettingsWithInventory(ctx context.Context, settings codexDesktopModelsSettings) (codexDesktopModelsSettings, error) {
|
||||
inventory, err := loadCodexDesktopModelInventory(ctx)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
|
|
|
|||
94
app/cmd/app/desktop_models_darwin.go
Normal file
94
app/cmd/app/desktop_models_darwin.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
"github.com/ollama/ollama/internal/proxy"
|
||||
)
|
||||
|
||||
// Discovery runs on the HTTP server, not WebKit's native message callback.
|
||||
func desktopModelSettingsHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
catalog := r.URL.Query().Get("catalog") == "true"
|
||||
var result any
|
||||
switch r.PathValue("integration") {
|
||||
case "claude-desktop":
|
||||
status := claudeDesktopSettingsSummary()
|
||||
if catalog && status.Installed && status.Used {
|
||||
status = claudeDesktopConnectionStatus(ctx)
|
||||
}
|
||||
result = status
|
||||
case "chatgpt":
|
||||
settings := codexDesktopSettingsSummary()
|
||||
response := codexDesktopModelsSettingsResult{Settings: settings}
|
||||
if catalog && settings.Installed {
|
||||
var err error
|
||||
response.Settings, err = codexDesktopSettingsWithInventory(ctx, settings)
|
||||
if err != nil {
|
||||
response.Warning = codexDesktopModelRefreshError(response.Settings)
|
||||
}
|
||||
}
|
||||
result = response
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
http.Error(w, "Model discovery timed out", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
}
|
||||
|
||||
func claudeDesktopSettingsSummary() claudeDesktopStatus {
|
||||
status := getClaudeDesktopConnectionSummary()
|
||||
selected := activeClaudeDesktopModels()
|
||||
mappings := proxy.ClaudeDesktopMappings(selected)
|
||||
if saved := launch.ClaudeDesktopModelMappings(); len(saved) > 0 {
|
||||
mappings = saved
|
||||
}
|
||||
for _, route := range proxy.ClaudeDesktopRoutes() {
|
||||
status.Mappings = append(status.Mappings, claudeDesktopMappingStatus{
|
||||
RouteID: route.ID, RouteName: route.DisplayName, Model: mappings[route.ID],
|
||||
})
|
||||
}
|
||||
for _, model := range selected {
|
||||
name := model.Name
|
||||
if model.Cloud {
|
||||
name = model.OllamaModel
|
||||
}
|
||||
status.Models = append(status.Models, claudeDesktopModelStatus{
|
||||
Name: name, DisplayName: name, Cloud: model.Cloud, Selected: true,
|
||||
Availability: "unknown", AutoMode: claudeDesktopModelSupportsAutoMode(model),
|
||||
})
|
||||
}
|
||||
for _, mapping := range status.Mappings {
|
||||
name := mapping.Model
|
||||
known := false
|
||||
for _, model := range status.Models {
|
||||
known = known || model.Name == name
|
||||
}
|
||||
if name != "" && !known {
|
||||
status.Models = append(status.Models, claudeDesktopModelStatus{
|
||||
Name: name, DisplayName: name, Selected: true, Availability: "unknown",
|
||||
})
|
||||
}
|
||||
}
|
||||
autoMode, err := launch.ClaudeDesktopAutoModeEnabled()
|
||||
status.AutoMode = autoMode
|
||||
if err != nil && status.Error == "" {
|
||||
status.Error = err.Error()
|
||||
}
|
||||
return status
|
||||
}
|
||||
149
app/cmd/app/desktop_models_darwin_test.go
Normal file
149
app/cmd/app/desktop_models_darwin_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
"github.com/ollama/ollama/internal/proxy"
|
||||
)
|
||||
|
||||
func TestDesktopModelsClaudeSummaryPreservesMappingsWithoutCatalog(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
previousStore, previousInstalled := appStore, claudeDesktopInstalled
|
||||
appStore = &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}
|
||||
claudeDesktopInstalled = func() bool { return false }
|
||||
claudeProxyMu.Lock()
|
||||
previousGateway, previousAvailable := claudeAppProxy, claudeAvailableModels
|
||||
claudeAppProxy, claudeAvailableModels = nil, nil
|
||||
claudeProxyMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
appStore.Close()
|
||||
appStore, claudeDesktopInstalled = previousStore, previousInstalled
|
||||
claudeProxyMu.Lock()
|
||||
claudeAppProxy, claudeAvailableModels = previousGateway, previousAvailable
|
||||
claudeProxyMu.Unlock()
|
||||
})
|
||||
if err := launch.SaveClaudeDesktopModelMappings(map[string]string{"claude-sonnet-5": "saved-model"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := claudeDesktopSettingsSummary()
|
||||
for _, mapping := range status.Mappings {
|
||||
if mapping.RouteID == "claude-sonnet-5" && mapping.Model == "saved-model" {
|
||||
for _, model := range status.Models {
|
||||
if model.Name == mapping.Model && model.Availability == "unknown" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("saved mapping missing from summary: %#v", status)
|
||||
}
|
||||
|
||||
func TestDesktopModelsSavedSettingsWithoutDiscovery(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
controller, factory := codexDesktop, codexDesktopClientFactory
|
||||
t.Cleanup(func() { codexDesktop, codexDesktopClientFactory = controller, factory })
|
||||
if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"saved-model"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codexDesktopClientFactory = func() (*api.Client, error) {
|
||||
t.Error("saved settings must not discover models")
|
||||
return nil, errors.New("discovery unavailable")
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
name, query string
|
||||
installed bool
|
||||
}{
|
||||
{"saved settings", "catalog=false", true},
|
||||
{"uninstalled app", "catalog=true", false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
codexDesktop = &fakeCodexDesktopController{installed: tt.installed}
|
||||
r := httptest.NewRequest(http.MethodGet, "/?"+tt.query, nil)
|
||||
r.SetPathValue("integration", "chatgpt")
|
||||
w := httptest.NewRecorder()
|
||||
desktopModelSettingsHandler().ServeHTTP(w, r)
|
||||
var result codexDesktopModelsSettingsResult
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != http.StatusOK || !slices.Equal(result.Settings.Selected, []string{"saved-model"}) || result.Warning != "" {
|
||||
t.Fatalf("response = %d %s", w.Code, w.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopModelsSlowDiscoveryDoesNotBlockSavedSettings(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
controller, factory := codexDesktop, codexDesktopClientFactory
|
||||
recommendations, access, cloud := codexDesktopRecommendations, codexDesktopAccessState, codexDesktopCloudModels
|
||||
t.Cleanup(func() {
|
||||
codexDesktop, codexDesktopClientFactory = controller, factory
|
||||
codexDesktopRecommendations, codexDesktopAccessState, codexDesktopCloudModels = recommendations, access, cloud
|
||||
})
|
||||
codexDesktop = &fakeCodexDesktopController{installed: true}
|
||||
codexDesktopClientFactory = func() (*api.Client, error) {
|
||||
return api.NewClient(&url.URL{Scheme: "http", Host: "127.0.0.1"}, http.DefaultClient), nil
|
||||
}
|
||||
started := make(chan struct{})
|
||||
codexDesktopRecommendations = func(ctx context.Context) ([]api.ModelRecommendation, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
codexDesktopAccessState = func(ctx context.Context) (proxy.ClaudeDesktopAccessState, error) {
|
||||
return proxy.ClaudeDesktopAccessState{}, ctx.Err()
|
||||
}
|
||||
codexDesktopCloudModels = func(ctx context.Context) ([]string, error) { return nil, ctx.Err() }
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
r := httptest.NewRequest(http.MethodGet, "/?catalog=true", nil).WithContext(ctx)
|
||||
r.SetPathValue("integration", "chatgpt")
|
||||
w := httptest.NewRecorder()
|
||||
handler := desktopModelSettingsHandler()
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); handler.ServeHTTP(w, r) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("discovery did not start")
|
||||
}
|
||||
summary := httptest.NewRequest(http.MethodGet, "/?catalog=false", nil)
|
||||
summary.SetPathValue("integration", "chatgpt")
|
||||
summaryDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(summaryDone)
|
||||
handler.ServeHTTP(httptest.NewRecorder(), summary)
|
||||
}()
|
||||
select {
|
||||
case <-summaryDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("saved settings waited for model discovery")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("discovery did not honor cancellation")
|
||||
}
|
||||
if w.Code != http.StatusGatewayTimeout {
|
||||
t.Fatalf("canceled discovery returned %d", w.Code)
|
||||
}
|
||||
}
|
||||
7
app/cmd/app/desktop_models_windows.go
Normal file
7
app/cmd/app/desktop_models_windows.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func desktopModelSettingsHandler() http.Handler { return nil }
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"test": "vitest",
|
||||
"test:browser": "vitest run --mode browser",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,9 +7,47 @@ vi.mock("./lib/ollama-client", () => ({
|
|||
import {
|
||||
fetchConnectUrl,
|
||||
getClaudeDesktopAvailableModels,
|
||||
getClaudeDesktopModelsSettings,
|
||||
getCodexDesktopModelsSettings,
|
||||
getIntegrationStatuses,
|
||||
} from "./api";
|
||||
|
||||
describe("desktop model settings", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("requests summaries and catalogs through the configured API server", async () => {
|
||||
const response = { settings: { selected: ["saved-model"] } };
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify(response)));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await expect(getCodexDesktopModelsSettings(false)).resolves.toEqual(
|
||||
response,
|
||||
);
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"http://127.0.0.1:3001/api/v1/integrations/chatgpt/models?catalog=false",
|
||||
{ signal: undefined },
|
||||
);
|
||||
fetch.mockResolvedValue(new Response(JSON.stringify({ installed: true })));
|
||||
const controller = new AbortController();
|
||||
await expect(
|
||||
getClaudeDesktopModelsSettings(true, controller.signal),
|
||||
).resolves.toEqual({ installed: true });
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"http://127.0.0.1:3001/api/v1/integrations/claude-desktop/models?catalog=true",
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects failed discovery responses", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response("timed out", { status: 504 })),
|
||||
);
|
||||
await expect(getCodexDesktopModelsSettings(true)).rejects.toThrow("504");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchConnectUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import { parseJsonlFromResponse } from "./util/jsonl-parsing";
|
|||
import { ollamaClient as ollama } from "./lib/ollama-client";
|
||||
import type { ModelResponse } from "ollama/browser";
|
||||
import { API_BASE, OLLAMA_DOT_COM } from "./lib/config";
|
||||
import type {
|
||||
ClaudeDesktopStatus,
|
||||
CodexDesktopModelsSettingsResult,
|
||||
} from "./types/webview";
|
||||
|
||||
// Extend Model class with utility methods
|
||||
declare module "@/gotypes" {
|
||||
|
|
@ -50,6 +54,46 @@ export async function getIntegrationStatuses(): Promise<IntegrationStatuses> {
|
|||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function getDesktopModelSettings<T>(
|
||||
integration: string,
|
||||
catalog: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/integrations/${integration}/models?catalog=${catalog}`,
|
||||
{ signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch ${integration} model settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function getClaudeDesktopModelsSettings(
|
||||
catalog: boolean,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return getDesktopModelSettings<ClaudeDesktopStatus>(
|
||||
"claude-desktop",
|
||||
catalog,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
export function getCodexDesktopModelsSettings(
|
||||
catalog: boolean,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return getDesktopModelSettings<CodexDesktopModelsSettingsResult>(
|
||||
"chatgpt",
|
||||
catalog,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to convert Uint8Array to base64
|
||||
function uint8ArrayToBase64(uint8Array: Uint8Array): string {
|
||||
const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import {
|
||||
getClaudeDesktopAvailableModels,
|
||||
getClaudeDesktopModelsSettings,
|
||||
} from "@/api";
|
||||
import { Model } from "@/gotypes";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import {
|
||||
act,
|
||||
create,
|
||||
|
|
@ -11,13 +17,26 @@ import {
|
|||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Switch } from "./ui/switch";
|
||||
import {
|
||||
ClaudeDesktopModelsSettings,
|
||||
type ClaudeDesktopModelsSettingsHandle,
|
||||
} from "./ClaudeDesktopModelsSettings";
|
||||
|
||||
vi.mock("@/api", () => ({
|
||||
getClaudeDesktopModelsSettings: vi.fn(),
|
||||
getClaudeDesktopAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getClaudeDesktopAvailableModels).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(getClaudeDesktopModelsSettings)
|
||||
.mockReset()
|
||||
.mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
afterEach(() => queryClient.clear());
|
||||
|
||||
vi.mock("@headlessui/react", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const original = await importOriginal<typeof import("@headlessui/react")>();
|
||||
|
|
@ -171,6 +190,259 @@ function textContent(node: ReactTestInstance): string {
|
|||
}
|
||||
|
||||
describe("ClaudeDesktopModelsSettings interactions", () => {
|
||||
it.each(["catalog", "inventory"])(
|
||||
"keeps the successful %s usable while the other request is pending or fails",
|
||||
async (successful) => {
|
||||
const catalog = testStatus();
|
||||
const summary = {
|
||||
...catalog,
|
||||
models: [{ ...catalog.models[0], availability: "unknown" as const }],
|
||||
};
|
||||
let resolveCatalog!: (status: typeof catalog) => void;
|
||||
let rejectCatalog!: (error: Error) => void;
|
||||
const pendingCatalog = new Promise<typeof catalog>((resolve, reject) => {
|
||||
resolveCatalog = resolve;
|
||||
rejectCatalog = reject;
|
||||
});
|
||||
let resolveInventory!: (models: Model[]) => void;
|
||||
let rejectInventory!: (error: Error) => void;
|
||||
const pendingInventory = new Promise<Model[]>((resolve, reject) => {
|
||||
resolveInventory = resolve;
|
||||
rejectInventory = reject;
|
||||
});
|
||||
vi.mocked(getClaudeDesktopModelsSettings).mockImplementation((full) =>
|
||||
full ? pendingCatalog : Promise.resolve(summary),
|
||||
);
|
||||
vi.mocked(getClaudeDesktopAvailableModels).mockReturnValue(
|
||||
pendingInventory,
|
||||
);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings initialStatus={summary} />,
|
||||
);
|
||||
});
|
||||
await act(async () => pickerButton(renderer!).props.onClick());
|
||||
await act(async () => {
|
||||
if (successful === "catalog") resolveCatalog(catalog);
|
||||
else resolveInventory([new Model({ model: "local-model" })]);
|
||||
});
|
||||
const name = successful === "catalog" ? "kimi-k3:cloud" : "local-model";
|
||||
const choice = () =>
|
||||
renderer!.root
|
||||
.findAllByProps({ role: "option" })
|
||||
.find((option) => textContent(option).includes(name));
|
||||
expect(choice()?.props.disabled).toBe(false);
|
||||
expect(textContent(renderer!.root)).toContain("Loading models…");
|
||||
|
||||
await act(async () => {
|
||||
if (successful === "catalog")
|
||||
rejectInventory(new Error("inventory unavailable"));
|
||||
else rejectCatalog(new Error("catalog unavailable"));
|
||||
});
|
||||
expect(choice()?.props.disabled).toBe(false);
|
||||
expect(textContent(renderer!.root)).not.toContain("Loading models…");
|
||||
await act(async () => choice()!.props.onClick());
|
||||
expect(textContent(pickerButton(renderer!))).toContain(name);
|
||||
} finally {
|
||||
resolveCatalog(catalog);
|
||||
resolveInventory([]);
|
||||
await act(async () => renderer?.unmount());
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps saved installed models usable after editing one route when the catalog fails (running: %s)",
|
||||
async (running) => {
|
||||
const names = ["saved-one", "saved-two", "another-installed"];
|
||||
const summary = {
|
||||
...testStatus(undefined, running),
|
||||
mappings: [
|
||||
{ ...fableRoute, model: names[0] },
|
||||
{
|
||||
routeId: "claude-opus-5",
|
||||
routeName: "Opus 5",
|
||||
model: names[1],
|
||||
},
|
||||
],
|
||||
models: names.slice(0, 2).map((name) => ({
|
||||
name,
|
||||
displayName: name,
|
||||
selected: true,
|
||||
availability: "unknown" as const,
|
||||
})),
|
||||
};
|
||||
vi.mocked(getClaudeDesktopModelsSettings).mockImplementation((full) =>
|
||||
full
|
||||
? Promise.reject(new Error("catalog unavailable"))
|
||||
: Promise.resolve(summary),
|
||||
);
|
||||
vi.mocked(getClaudeDesktopAvailableModels).mockResolvedValue(
|
||||
names.map((model) => new Model({ model })),
|
||||
);
|
||||
const apply = vi.fn().mockResolvedValue({
|
||||
status: {
|
||||
...summary,
|
||||
mappings: [
|
||||
{ ...summary.mappings[0], model: names[2] },
|
||||
summary.mappings[1],
|
||||
],
|
||||
models: names.map((name) => ({
|
||||
name,
|
||||
displayName: name,
|
||||
selected: name !== names[0],
|
||||
availability: "available" as const,
|
||||
})),
|
||||
},
|
||||
mappingsApplied: true,
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
applyClaudeDesktopMappings: apply,
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings initialStatus={summary} />,
|
||||
);
|
||||
});
|
||||
await act(async () => pickerButton(renderer!).props.onClick());
|
||||
const options = renderer!.root.findAllByProps({ role: "option" });
|
||||
expect(options).toHaveLength(names.length);
|
||||
for (const name of names) {
|
||||
const option = options.find((option) => textContent(option) === name);
|
||||
expect(option?.props.disabled).toBe(false);
|
||||
}
|
||||
await act(async () => {
|
||||
options
|
||||
.find((option) => textContent(option) === names[2])!
|
||||
.props.onClick();
|
||||
});
|
||||
expect(actionButton(renderer!).props.disabled).not.toBe(true);
|
||||
expect(textContent(actionButton(renderer!))).toBe(
|
||||
running ? "Restart Claude" : "Start Claude",
|
||||
);
|
||||
await act(async () => actionButton(renderer!).props.onClick());
|
||||
expect(apply).toHaveBeenCalledWith(
|
||||
{ "claude-fable-5": names[2], "claude-opus-5": names[1] },
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps unavailable models and unconfirmed placeholders disabled after inventory succeeds", async () => {
|
||||
const summary = {
|
||||
...testStatus(),
|
||||
models: [
|
||||
{
|
||||
...testStatus().models[0],
|
||||
availability: "unavailable" as const,
|
||||
reason: "upgrade_required" as const,
|
||||
},
|
||||
{
|
||||
...testStatus().models[1],
|
||||
availability: "unknown" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(getClaudeDesktopModelsSettings).mockImplementation((full) =>
|
||||
full
|
||||
? Promise.reject(new Error("catalog unavailable"))
|
||||
: Promise.resolve(summary),
|
||||
);
|
||||
vi.mocked(getClaudeDesktopAvailableModels).mockResolvedValue([
|
||||
new Model({ model: summary.models[0].name }),
|
||||
new Model({ model: "local-model" }),
|
||||
]);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings initialStatus={summary} />,
|
||||
);
|
||||
});
|
||||
await act(async () => pickerButton(renderer!).props.onClick());
|
||||
const options = renderer!.root.findAllByProps({ role: "option" });
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0].props.disabled).toBe(true);
|
||||
expect(textContent(options[0])).toContain("Upgrade required");
|
||||
expect(options[1].props.disabled).toBe(true);
|
||||
expect(options[2].props.disabled).toBe(false);
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the picker loading until the latest refresh finishes", async () => {
|
||||
let focusHandler: (() => void) | undefined;
|
||||
let finishFirst!: (status: ReturnType<typeof testStatus>) => void;
|
||||
let finishLatest!: (status: ReturnType<typeof testStatus>) => void;
|
||||
const first = new Promise<ReturnType<typeof testStatus>>(
|
||||
(resolve) => (finishFirst = resolve),
|
||||
);
|
||||
const latest = new Promise<ReturnType<typeof testStatus>>(
|
||||
(resolve) => (finishLatest = resolve),
|
||||
);
|
||||
let summaries = 0;
|
||||
vi.mocked(getClaudeDesktopModelsSettings).mockImplementation((catalog) =>
|
||||
catalog
|
||||
? summaries === 1
|
||||
? first
|
||||
: latest
|
||||
: Promise.resolve(testStatus("glm-5.2:cloud", summaries++ > 0)),
|
||||
);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn((event, handler) => {
|
||||
if (event === "focus") focusHandler = handler;
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={testStatus()}
|
||||
initialLocalModels={[]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => pickerButton(renderer!).props.onClick());
|
||||
await act(async () => focusHandler?.());
|
||||
await act(async () => finishFirst(testStatus()));
|
||||
expect(textContent(renderer!.root)).toContain("Loading models…");
|
||||
await act(async () => finishLatest(testStatus("glm-5.2:cloud", true)));
|
||||
expect(textContent(renderer!.root)).not.toContain("Loading models…");
|
||||
} finally {
|
||||
finishFirst(testStatus());
|
||||
finishLatest(testStatus());
|
||||
await act(async () => renderer?.unmount());
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens below without scrolling and disables auto mode for draft changes", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
|
|
@ -432,13 +704,13 @@ describe("ClaudeDesktopModelsSettings interactions", () => {
|
|||
resolveRefresh = resolve;
|
||||
},
|
||||
);
|
||||
vi.mocked(getClaudeDesktopModelsSettings).mockReturnValue(staleRefresh);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === "focus") focusHandler = handler;
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
getClaudeDesktopStatus: vi.fn(() => staleRefresh),
|
||||
applyClaudeDesktopMappings: vi.fn().mockResolvedValue({
|
||||
status: testStatus("kimi-k3:cloud"),
|
||||
mappingsApplied: true,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { getClaudeDesktopAvailableModels } from "@/api";
|
||||
import { desktopModels, invalidateDesktopModels } from "@/lib/desktopModels";
|
||||
import {
|
||||
getClaudeDesktopAvailableModels,
|
||||
getClaudeDesktopModelsSettings,
|
||||
} from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Description, Field, Label } from "@/components/ui/fieldset";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -37,6 +41,7 @@ interface ClaudeDesktopModelsSettingsProps {
|
|||
initialLocalModels?: string[];
|
||||
initialCloudModels?: string[];
|
||||
includeCloudModels?: boolean;
|
||||
accountKey?: string;
|
||||
onDraftChange?: (hasChanges: boolean) => void;
|
||||
showSectionHeading?: boolean;
|
||||
}
|
||||
|
|
@ -137,6 +142,8 @@ interface ClaudeModelPickerProps {
|
|||
models: ClaudeDesktopModelStatus[];
|
||||
disabled: boolean;
|
||||
onChange: (model: string) => void;
|
||||
onOpen: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function ClaudeModelPicker({
|
||||
|
|
@ -146,6 +153,8 @@ function ClaudeModelPicker({
|
|||
models,
|
||||
disabled,
|
||||
onChange,
|
||||
onOpen,
|
||||
loading,
|
||||
}: ClaudeModelPickerProps) {
|
||||
return (
|
||||
<Popover className="relative min-w-0">
|
||||
|
|
@ -169,15 +178,23 @@ function ClaudeModelPicker({
|
|||
className="z-50 flex w-[var(--button-width)] min-w-64 flex-col overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 [--anchor-max-height:19rem] dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white"
|
||||
>
|
||||
{({ close }) => (
|
||||
<ClaudeModelPickerOptions
|
||||
routeName={routeName}
|
||||
value={value}
|
||||
models={models}
|
||||
onChange={(model) => {
|
||||
onChange(model);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{loading && (
|
||||
<p role="status" className="px-3 py-2 text-neutral-400">
|
||||
Loading models…
|
||||
</p>
|
||||
)}
|
||||
<ClaudeModelPickerOptions
|
||||
routeName={routeName}
|
||||
value={value}
|
||||
models={models}
|
||||
onOpen={onOpen}
|
||||
onChange={(model) => {
|
||||
onChange(model);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
|
|
@ -189,9 +206,10 @@ function ClaudeModelPickerOptions({
|
|||
value,
|
||||
models,
|
||||
onChange,
|
||||
onOpen,
|
||||
}: Pick<
|
||||
ClaudeModelPickerProps,
|
||||
"routeName" | "value" | "models" | "onChange"
|
||||
"routeName" | "value" | "models" | "onChange" | "onOpen"
|
||||
>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -204,6 +222,10 @@ function ClaudeModelPickerOptions({
|
|||
searchRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onOpen();
|
||||
}, [onOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-none items-center gap-2 border-b border-neutral-100 px-3 py-2 dark:border-neutral-700">
|
||||
|
|
@ -266,6 +288,7 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
initialLocalModels,
|
||||
initialCloudModels,
|
||||
includeCloudModels = false,
|
||||
accountKey,
|
||||
onDraftChange,
|
||||
showSectionHeading = true,
|
||||
},
|
||||
|
|
@ -289,7 +312,8 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
const [accountCloudModels, setAccountCloudModels] = useState<string[]>(
|
||||
initialCloudModels ?? [],
|
||||
);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const catalogRequested = useRef(false);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [resettingMappings, setResettingMappings] = useState(false);
|
||||
|
|
@ -319,61 +343,92 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
[],
|
||||
);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!window.getClaudeDesktopStatus) return;
|
||||
const request = ++statusRequestRef.current;
|
||||
try {
|
||||
const next = await window.getClaudeDesktopStatus();
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
applyStatus(next, true);
|
||||
const refreshStatus = useCallback(
|
||||
async (catalog = catalogRequested.current) => {
|
||||
const request = ++statusRequestRef.current;
|
||||
const isCurrent = () =>
|
||||
request === statusRequestRef.current && !operationInFlightRef.current;
|
||||
try {
|
||||
const summary = await getClaudeDesktopModelsSettings(false);
|
||||
if (!isCurrent()) return;
|
||||
const discover = catalog && summary.used && summary.installed;
|
||||
setCatalogLoading(discover);
|
||||
const results = await Promise.allSettled([
|
||||
(discover
|
||||
? desktopModels(
|
||||
[
|
||||
"claude",
|
||||
accountKey,
|
||||
includeCloudModels,
|
||||
{ ...summary, routedRequests: undefined },
|
||||
],
|
||||
(signal) => getClaudeDesktopModelsSettings(true, signal),
|
||||
)
|
||||
: Promise.resolve(summary)
|
||||
).then((next) => {
|
||||
if (isCurrent()) {
|
||||
applyStatus(
|
||||
{ ...next, routedRequests: summary.routedRequests },
|
||||
true,
|
||||
);
|
||||
}
|
||||
}),
|
||||
(discover && !initialLocalModels
|
||||
? desktopModels(
|
||||
["claude", "inventory", accountKey, includeCloudModels],
|
||||
() => getClaudeDesktopAvailableModels(includeCloudModels),
|
||||
)
|
||||
: Promise.resolve(null)
|
||||
).then((installed) => {
|
||||
if (!isCurrent() || !installed) return;
|
||||
setLocalModels(installed.map((model) => model.model));
|
||||
setAccountCloudModels(
|
||||
installed
|
||||
.filter((model) => model.isCloud())
|
||||
.map((model) => model.model),
|
||||
);
|
||||
}),
|
||||
]);
|
||||
if (isCurrent()) {
|
||||
if (results[0].status === "rejected")
|
||||
setError("Ollama could not read the Claude connection status.");
|
||||
else if (results[1].status === "rejected")
|
||||
setError("Ollama could not load your models.");
|
||||
}
|
||||
} catch {
|
||||
if (isCurrent()) {
|
||||
setError("Ollama could not read the Claude connection status.");
|
||||
}
|
||||
} finally {
|
||||
if (request === statusRequestRef.current) setCatalogLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
setError("Ollama could not read the Claude connection status.");
|
||||
}
|
||||
}
|
||||
}, [applyStatus]);
|
||||
},
|
||||
[accountKey, includeCloudModels, initialLocalModels, applyStatus],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialStatus) void refreshStatus();
|
||||
const handleFocus = () => void refreshStatus();
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => window.removeEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
++statusRequestRef.current;
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [initialStatus, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLocalModels || !status?.used) return;
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
void getClaudeDesktopAvailableModels(includeCloudModels)
|
||||
.then((installed) => {
|
||||
if (!cancelled) {
|
||||
setLocalModels(installed.map((model) => model.model));
|
||||
setAccountCloudModels(
|
||||
installed
|
||||
.filter((model) => model.isCloud())
|
||||
.map((model) => model.model),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("Ollama could not load your models.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setModelsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [includeCloudModels, initialLocalModels, status?.used]);
|
||||
const openCatalog = useCallback(() => {
|
||||
catalogRequested.current = true;
|
||||
setCatalogLoading(true);
|
||||
void refreshStatus(true);
|
||||
}, [refreshStatus]);
|
||||
|
||||
const catalogModels = useMemo(() => {
|
||||
const installedNames = new Set(localModels);
|
||||
const resolved: ClaudeDesktopModelStatus[] = models.map((model) =>
|
||||
model.availability === "unknown" && installedNames.has(model.name)
|
||||
? { ...model, availability: "available" }
|
||||
: model,
|
||||
);
|
||||
const current = new Set(models.map((model) => model.name));
|
||||
const installed: ClaudeDesktopModelStatus[] = localModels
|
||||
.filter((name) => !current.has(name) && !isInvalidModelName(name))
|
||||
|
|
@ -384,7 +439,7 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
selected: false,
|
||||
availability: "available",
|
||||
}));
|
||||
return [...models, ...installed];
|
||||
return [...resolved, ...installed];
|
||||
}, [localModels, models]);
|
||||
|
||||
const hasDraftChanges = !mappingsEqual(mappings, savedMappings);
|
||||
|
|
@ -458,7 +513,7 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
setError("Choose at least one Ollama model for Claude.");
|
||||
return;
|
||||
}
|
||||
if (hasInvalidMapping) {
|
||||
if (hasDraftChanges && hasInvalidMapping) {
|
||||
setError("Choose models available to your account and device.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -466,9 +521,11 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
|
||||
const mappingsToApply = mappingRecord(mappings);
|
||||
setApplying(true);
|
||||
setCatalogLoading(false);
|
||||
setError(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("claude");
|
||||
try {
|
||||
await runMappingAction(
|
||||
(restartConfirmed) => applyMappings(mappingsToApply, restartConfirmed),
|
||||
|
|
@ -476,6 +533,7 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
);
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("claude");
|
||||
operationInFlightRef.current = false;
|
||||
setApplying(false);
|
||||
}
|
||||
|
|
@ -489,8 +547,10 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
setError(null);
|
||||
setAutoModeOverride(checked);
|
||||
setAutoModeApplying(true);
|
||||
setCatalogLoading(false);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("claude");
|
||||
try {
|
||||
let result = await window.setClaudeDesktopAutoMode(checked, false);
|
||||
if (result.restartConfirmationRequired) {
|
||||
|
|
@ -511,6 +571,7 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
setError("Ollama could not update Claude auto mode.");
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("claude");
|
||||
operationInFlightRef.current = false;
|
||||
setAutoModeOverride(null);
|
||||
setAutoModeApplying(false);
|
||||
|
|
@ -527,9 +588,11 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
}
|
||||
|
||||
setResettingMappings(true);
|
||||
setCatalogLoading(false);
|
||||
setError(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("claude");
|
||||
try {
|
||||
return await runMappingAction(
|
||||
resetMappings,
|
||||
|
|
@ -537,6 +600,7 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
);
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("claude");
|
||||
operationInFlightRef.current = false;
|
||||
setResettingMappings(false);
|
||||
}
|
||||
|
|
@ -619,7 +683,7 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
disabled={
|
||||
busy ||
|
||||
assignedModels.length === 0 ||
|
||||
hasInvalidMapping ||
|
||||
(hasDraftChanges && hasInvalidMapping) ||
|
||||
(status.running && !hasDraftChanges)
|
||||
}
|
||||
className="flex-shrink-0"
|
||||
|
|
@ -659,7 +723,9 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
|||
id={`claude-route-${mapping.routeId}`}
|
||||
routeName={mapping.routeName}
|
||||
value={mapping.model ?? ""}
|
||||
disabled={busy || modelsLoading}
|
||||
disabled={busy}
|
||||
loading={catalogLoading}
|
||||
onOpen={openCatalog}
|
||||
models={catalogModels}
|
||||
onChange={(model) =>
|
||||
updateMapping(mapping.routeId, model)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { queryClient } from "@/lib/queryClient";
|
||||
import { getCodexDesktopModelsSettings } from "@/api";
|
||||
import { desktopModels, invalidateDesktopModels } from "@/lib/desktopModels";
|
||||
import type {
|
||||
CodexDesktopModelsSettings as ModelsSettings,
|
||||
CodexDesktopStatus,
|
||||
|
|
@ -10,12 +13,14 @@ import type {
|
|||
} from "react";
|
||||
import { createRef } from "react";
|
||||
import { act, create } from "react-test-renderer";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CodexDesktopModelsSettings,
|
||||
type CodexDesktopModelsSettingsHandle,
|
||||
} from "./CodexDesktopModelsSettings";
|
||||
|
||||
vi.mock("@/api", () => ({ getCodexDesktopModelsSettings: vi.fn() }));
|
||||
|
||||
vi.mock("@headlessui/react", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const original = await importOriginal<typeof import("@headlessui/react")>();
|
||||
|
|
@ -138,13 +143,29 @@ function codexStatus(
|
|||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Native-action tests can leave unrelated settings reads pending.
|
||||
vi.mocked(getCodexDesktopModelsSettings)
|
||||
.mockReset()
|
||||
.mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("CodexDesktopModelsSettings", () => {
|
||||
it("resets to native recommendation defaults through its settings handle", async () => {
|
||||
let finishClaude!: (models: string[]) => void;
|
||||
const claudeDiscovery = desktopModels(
|
||||
["claude", "account-a"],
|
||||
() => new Promise<string[]>((resolve) => (finishClaude = resolve)),
|
||||
).then(
|
||||
(models) => ({ models }),
|
||||
(error) => ({ error }),
|
||||
);
|
||||
const resetModels = vi.fn().mockResolvedValue({
|
||||
settings: settings({
|
||||
usesDefaults: true,
|
||||
|
|
@ -175,6 +196,8 @@ describe("CodexDesktopModelsSettings", () => {
|
|||
});
|
||||
|
||||
expect(succeeded).toBe(true);
|
||||
finishClaude(["claude-model"]);
|
||||
expect(await claudeDiscovery).toEqual({ models: ["claude-model"] });
|
||||
expect(resetModels).toHaveBeenCalledWith();
|
||||
expect(
|
||||
renderer!.root.findAllByProps({
|
||||
|
|
@ -190,6 +213,7 @@ describe("CodexDesktopModelsSettings", () => {
|
|||
renderer!.root.findAllByProps({ "aria-label": "Remove qwen3:8b" }),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
finishClaude([]);
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
|
|
@ -240,7 +264,10 @@ describe("CodexDesktopModelsSettings", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("stops loading and explains when the native settings bridge is unavailable", async () => {
|
||||
it("stops loading and explains when the settings request fails", async () => {
|
||||
vi.mocked(getCodexDesktopModelsSettings).mockRejectedValue(
|
||||
new Error("unavailable"),
|
||||
);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
|
|
@ -253,7 +280,7 @@ describe("CodexDesktopModelsSettings", () => {
|
|||
});
|
||||
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
"ChatGPT model settings are unavailable in this Ollama build.",
|
||||
"Ollama could not load the ChatGPT model settings.",
|
||||
);
|
||||
expect(textContent(renderer!.root)).not.toContain("Loading models…");
|
||||
} finally {
|
||||
|
|
@ -262,20 +289,20 @@ describe("CodexDesktopModelsSettings", () => {
|
|||
});
|
||||
|
||||
it("keeps restart available when live model inventory cannot refresh", async () => {
|
||||
vi.mocked(getCodexDesktopModelsSettings).mockResolvedValue({
|
||||
settings: settings({
|
||||
connected: true,
|
||||
running: true,
|
||||
selected: ["qwen3:8b"],
|
||||
available: [],
|
||||
}),
|
||||
warning:
|
||||
"Couldn’t refresh available models. Your saved models are unchanged.",
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getCodexDesktopModelsSettings: vi.fn().mockResolvedValue({
|
||||
settings: settings({
|
||||
connected: true,
|
||||
running: true,
|
||||
selected: ["qwen3:8b"],
|
||||
available: [],
|
||||
}),
|
||||
warning:
|
||||
"Couldn’t refresh available models. Your saved models are unchanged.",
|
||||
}),
|
||||
});
|
||||
|
||||
let renderer;
|
||||
|
|
@ -419,11 +446,11 @@ describe("CodexDesktopModelsSettings", () => {
|
|||
selected: recommendationDefaults,
|
||||
}),
|
||||
});
|
||||
vi.mocked(getCodexDesktopModelsSettings).mockImplementation(refresh);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getCodexDesktopModelsSettings: refresh,
|
||||
});
|
||||
|
||||
let renderer;
|
||||
|
|
@ -926,6 +953,7 @@ describe("CodexDesktopModelsSettings", () => {
|
|||
resolveRefresh = resolve;
|
||||
}),
|
||||
);
|
||||
vi.mocked(getCodexDesktopModelsSettings).mockImplementation(refresh);
|
||||
const apply = vi.fn().mockResolvedValue({
|
||||
settings: settings({ connected: true, running: true, selected: next }),
|
||||
});
|
||||
|
|
@ -936,7 +964,6 @@ describe("CodexDesktopModelsSettings", () => {
|
|||
if (event === "focus") focusHandler = handler;
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
getCodexDesktopModelsSettings: refresh,
|
||||
applyCodexDesktopModels: apply,
|
||||
confirm: vi.fn(() => true),
|
||||
});
|
||||
|
|
@ -1092,3 +1119,407 @@ describe("CodexDesktopModelsSettings", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("shows saved selections before discovering models on picker open", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
const summary = settings({
|
||||
selected: ["saved-model"],
|
||||
available: [],
|
||||
models: [],
|
||||
});
|
||||
let resolve!: (value: { settings: ModelsSettings }) => void;
|
||||
const pending = new Promise<{ settings: ModelsSettings }>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const read = vi.fn((catalog: boolean) =>
|
||||
catalog ? pending : Promise.resolve({ settings: summary }),
|
||||
);
|
||||
vi.mocked(getCodexDesktopModelsSettings).mockImplementation((catalog) =>
|
||||
read(catalog),
|
||||
);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(<CodexDesktopModelsSettings />);
|
||||
});
|
||||
expect(read.mock.calls).toEqual([[false]]);
|
||||
expect(textContent(renderer!.root)).toContain("saved-model");
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findAllByType("button")
|
||||
.find((button) => button.props["aria-label"] === "Add ChatGPT model")!
|
||||
.props.onClick({});
|
||||
});
|
||||
expect(read.mock.calls).toEqual([[false], [false], [true]]);
|
||||
expect(textContent(renderer!.root)).toContain("Loading models…");
|
||||
expect(textContent(renderer!.root)).toContain("saved-model");
|
||||
await act(async () => {
|
||||
resolve({ settings: settings({ selected: ["saved-model"] }) });
|
||||
});
|
||||
expect(textContent(renderer!.root)).not.toContain("Loading models…");
|
||||
} finally {
|
||||
resolve({ settings: summary });
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
describe("implicit recommendation defaults", () => {
|
||||
const summary = settings({
|
||||
usesDefaults: true,
|
||||
selected: null as unknown as string[],
|
||||
available: [],
|
||||
models: [],
|
||||
});
|
||||
const catalog = settings({ usesDefaults: true });
|
||||
|
||||
function pendingCatalog() {
|
||||
let resolve!: (value: {
|
||||
settings: ModelsSettings;
|
||||
warning?: string;
|
||||
}) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<{ settings: ModelsSettings; warning?: string }>(
|
||||
(done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
},
|
||||
);
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function stubReads(catalogResponse = Promise.resolve({ settings: catalog })) {
|
||||
let focus: (() => void) | undefined;
|
||||
const read = vi.fn((includeCatalog: boolean) =>
|
||||
includeCatalog ? catalogResponse : Promise.resolve({ settings: summary }),
|
||||
);
|
||||
vi.mocked(getCodexDesktopModelsSettings).mockImplementation((catalog) =>
|
||||
read(catalog),
|
||||
);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn((event, handler) => {
|
||||
if (event === "focus") focus = handler;
|
||||
}),
|
||||
removeEventListener: vi.fn((event, handler) => {
|
||||
if (event === "focus" && focus === handler) focus = undefined;
|
||||
}),
|
||||
});
|
||||
return { read, focus: () => focus?.() };
|
||||
}
|
||||
|
||||
function selectedModels(renderer: ReturnType<typeof create>) {
|
||||
return renderer.root
|
||||
.findAllByType("button")
|
||||
.filter((button) => button.props["aria-label"]?.startsWith("Remove "))
|
||||
.map((button) => button.props["aria-label"].slice(7));
|
||||
}
|
||||
|
||||
async function openPicker(renderer: ReturnType<typeof create>) {
|
||||
await act(async () => {
|
||||
renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button) => button.props["aria-label"] === "Add ChatGPT model")!
|
||||
.props.onClick({});
|
||||
});
|
||||
}
|
||||
|
||||
it("shows usable settings while defaults load, then fills the names without a click", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = pendingCatalog();
|
||||
const { read } = stubReads(pending.promise);
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings accountKey="account-a" />,
|
||||
);
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain("Recommended models");
|
||||
expect(read.mock.calls).toEqual([[false], [true]]);
|
||||
expect(selectedModels(renderer!)).toEqual([]);
|
||||
const picker = renderer!.root
|
||||
.findAllByType("button")
|
||||
.find((button) => button.props["aria-label"] === "Add ChatGPT model")!;
|
||||
expect(picker.props.disabled).toBe(false);
|
||||
const start = renderer!.root
|
||||
.findAllByType("button")
|
||||
.find((button) => textContent(button) === "Start ChatGPT")!;
|
||||
expect(Boolean(start.props.disabled)).toBe(false);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain("Recommended models");
|
||||
expect(picker.props.disabled).toBe(false);
|
||||
await act(async () => {
|
||||
pending.resolve({ settings: catalog });
|
||||
});
|
||||
expect(selectedModels(renderer!)).toEqual(recommendationDefaults);
|
||||
expect(textContent(renderer!.root)).not.toContain("Recommended models");
|
||||
expect(read.mock.calls).toEqual([[false], [true]]);
|
||||
} finally {
|
||||
pending.resolve({ settings: catalog });
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("shares background discovery with a picker opened while it is pending", async () => {
|
||||
const pending = pendingCatalog();
|
||||
const { read } = stubReads(pending.promise);
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings accountKey="account-a" />,
|
||||
);
|
||||
});
|
||||
await openPicker(renderer!);
|
||||
expect(textContent(renderer!.root)).toContain("Loading models…");
|
||||
expect(read.mock.calls).toEqual([[false], [true], [false]]);
|
||||
await act(async () => {
|
||||
pending.resolve({ settings: catalog });
|
||||
});
|
||||
expect(selectedModels(renderer!)).toEqual(recommendationDefaults);
|
||||
expect(textContent(renderer!.root)).not.toContain("Loading models…");
|
||||
expect(read.mock.calls).toEqual([[false], [true], [false]]);
|
||||
} finally {
|
||||
pending.resolve({ settings: catalog });
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores an old warning without canceling the current discovery", async () => {
|
||||
const first = pendingCatalog();
|
||||
const latest = pendingCatalog();
|
||||
const { read, focus } = stubReads();
|
||||
let summaries = 0;
|
||||
read.mockImplementation((catalog) =>
|
||||
catalog
|
||||
? summaries === 1
|
||||
? first.promise
|
||||
: latest.promise
|
||||
: Promise.resolve({
|
||||
settings: { ...summary, running: summaries++ > 0 },
|
||||
}),
|
||||
);
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings accountKey="account-a" />,
|
||||
);
|
||||
});
|
||||
await act(async () => focus());
|
||||
await act(async () =>
|
||||
first.resolve({ settings: summary, warning: "Old discovery failed" }),
|
||||
);
|
||||
await act(async () =>
|
||||
latest.resolve({ settings: { ...catalog, running: true } }),
|
||||
);
|
||||
expect(selectedModels(renderer!)).toEqual(recommendationDefaults);
|
||||
expect(textContent(renderer!.root)).not.toContain("could not load");
|
||||
expect(textContent(renderer!.root)).not.toContain("Old discovery failed");
|
||||
} finally {
|
||||
first.resolve({ settings: catalog });
|
||||
latest.resolve({ settings: catalog });
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"shows cached names during background refresh and preserves edits=%s",
|
||||
async (edit) => {
|
||||
vi.useFakeTimers();
|
||||
const { read } = stubReads();
|
||||
const pending = pendingCatalog();
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings accountKey="account-a" />,
|
||||
);
|
||||
});
|
||||
expect(selectedModels(renderer!)).toEqual(recommendationDefaults);
|
||||
await act(async () => renderer!.unmount());
|
||||
await vi.advanceTimersByTimeAsync(35_000);
|
||||
read.mockImplementation((includeCatalog) =>
|
||||
includeCatalog
|
||||
? pending.promise
|
||||
: Promise.resolve({ settings: summary }),
|
||||
);
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings accountKey="account-a" />,
|
||||
);
|
||||
});
|
||||
expect(selectedModels(renderer!)).toEqual(recommendationDefaults);
|
||||
expect(read.mock.calls).toEqual([[false], [true], [false], [true]]);
|
||||
if (edit) {
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findByProps({
|
||||
"aria-label": `Remove ${recommendationDefaults[4]}`,
|
||||
})
|
||||
.props.onClick({ stopPropagation: vi.fn() });
|
||||
});
|
||||
}
|
||||
const updated = [...recommendationDefaults].reverse();
|
||||
await act(async () => {
|
||||
pending.resolve({
|
||||
settings: settings({ usesDefaults: true, selected: updated }),
|
||||
});
|
||||
});
|
||||
expect(selectedModels(renderer!)).toEqual(
|
||||
edit ? recommendationDefaults.slice(0, 4) : updated,
|
||||
);
|
||||
} finally {
|
||||
pending.resolve({ settings: catalog });
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["account", "invalidation", "saved selection"])(
|
||||
"does not restore defaults after %s changes",
|
||||
async (change) => {
|
||||
const { read } = stubReads();
|
||||
const pending = pendingCatalog();
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings accountKey="account-a" />,
|
||||
);
|
||||
});
|
||||
await act(async () => renderer!.unmount());
|
||||
if (change === "invalidation") await invalidateDesktopModels("chatgpt");
|
||||
read.mockImplementation((includeCatalog) =>
|
||||
includeCatalog
|
||||
? pending.promise
|
||||
: Promise.resolve({
|
||||
settings:
|
||||
change === "saved selection"
|
||||
? settings({ selected: ["saved-model"] })
|
||||
: summary,
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings
|
||||
accountKey={change === "account" ? "account-b" : "account-a"}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(selectedModels(renderer!)).toEqual(
|
||||
change === "saved selection" ? ["saved-model"] : [],
|
||||
);
|
||||
if (change !== "saved selection") {
|
||||
expect(textContent(renderer!.root)).toContain("Recommended models");
|
||||
}
|
||||
expect(read.mock.calls).toEqual(
|
||||
change === "saved selection"
|
||||
? [[false], [true], [false]]
|
||||
: [[false], [true], [false], [true]],
|
||||
);
|
||||
} finally {
|
||||
pending.resolve({ settings: catalog });
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps settings usable after a failed background fetch and retries on picker open", async () => {
|
||||
const pending = pendingCatalog();
|
||||
const { read } = stubReads(pending.promise);
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings accountKey="account-a" />,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
pending.reject(new Error("offline"));
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain("Recommended models");
|
||||
expect(textContent(renderer!.root)).toContain("could not load");
|
||||
const picker = renderer!.root
|
||||
.findAllByType("button")
|
||||
.find((button) => button.props["aria-label"] === "Add ChatGPT model")!;
|
||||
expect(picker.props.disabled).toBe(false);
|
||||
read.mockImplementation(async (includeCatalog) => ({
|
||||
settings: includeCatalog ? catalog : summary,
|
||||
}));
|
||||
await openPicker(renderer!);
|
||||
expect(selectedModels(renderer!)).toEqual(recommendationDefaults);
|
||||
expect(textContent(renderer!.root)).not.toContain("could not load");
|
||||
expect(read.mock.calls).toEqual([[false], [true], [false], [true]]);
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps defaults returned by reset through focus and remount before opening a picker", async () => {
|
||||
const { read, focus } = stubReads();
|
||||
const reset = vi.fn().mockResolvedValue({ settings: catalog });
|
||||
window.resetCodexDesktopModels = reset;
|
||||
const ref = createRef<CodexDesktopModelsSettingsHandle>();
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings
|
||||
ref={ref}
|
||||
accountKey="account-a"
|
||||
initialSettings={settings({ selected: ["saved-model"] })}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await ref.current!.resetToDefaults();
|
||||
});
|
||||
expect(selectedModels(renderer!)).toEqual(recommendationDefaults);
|
||||
await act(async () => {
|
||||
focus();
|
||||
});
|
||||
expect(selectedModels(renderer!)).toEqual(recommendationDefaults);
|
||||
await act(async () => renderer!.unmount());
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings accountKey="account-a" />,
|
||||
);
|
||||
});
|
||||
expect(selectedModels(renderer!)).toEqual(recommendationDefaults);
|
||||
expect(read.mock.calls).toEqual([[false], [false]]);
|
||||
expect(reset).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("does not describe an empty edited selection as recommended defaults", async () => {
|
||||
stubReads();
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodexDesktopModelsSettings initialSettings={catalog} />,
|
||||
);
|
||||
});
|
||||
for (const model of recommendationDefaults) {
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findByProps({ "aria-label": `Remove ${model}` })
|
||||
.props.onClick({ stopPropagation: vi.fn() });
|
||||
});
|
||||
}
|
||||
expect(textContent(renderer!.root)).toContain("Select models");
|
||||
expect(textContent(renderer!.root)).not.toContain("Recommended models");
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
import { getCodexDesktopModelsSettings } from "@/api";
|
||||
import {
|
||||
cacheDesktopModels,
|
||||
cachedDesktopModels,
|
||||
desktopModels,
|
||||
invalidateDesktopModels,
|
||||
} from "@/lib/desktopModels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
CodexDesktopModelStatus,
|
||||
|
|
@ -96,6 +103,36 @@ function normalizeSettings(
|
|||
};
|
||||
}
|
||||
|
||||
function modelsCacheKey(settings: ModelsSettings, accountKey?: string) {
|
||||
// Catalog and mutation responses include resolved defaults and inventory;
|
||||
// match them to the saved-settings summary used before opening a picker.
|
||||
return [
|
||||
"chatgpt",
|
||||
accountKey,
|
||||
{
|
||||
...settings,
|
||||
selected: settings.usesDefaults ? null : settings.selected,
|
||||
available: [],
|
||||
models: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function rememberDefaults(
|
||||
result: CodexDesktopModelsSettingsResult | undefined,
|
||||
accountKey?: string,
|
||||
) {
|
||||
if (
|
||||
result?.settings.usesDefaults &&
|
||||
result.settings.selected?.length &&
|
||||
!result.error &&
|
||||
!result.warning &&
|
||||
!result.restartConfirmationRequired
|
||||
) {
|
||||
cacheDesktopModels(modelsCacheKey(result.settings, accountKey), result);
|
||||
}
|
||||
}
|
||||
|
||||
function modelIsAvailable(model: CodexDesktopModelStatus): boolean {
|
||||
return !model.availability || model.availability === "available";
|
||||
}
|
||||
|
|
@ -127,11 +164,13 @@ function ModelOptions({
|
|||
selected,
|
||||
maxModels,
|
||||
onToggle,
|
||||
onOpen,
|
||||
}: {
|
||||
models: CodexDesktopModelStatus[];
|
||||
selected: string[];
|
||||
maxModels: number;
|
||||
onToggle: (model: string) => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
|
|
@ -149,6 +188,10 @@ function ModelOptions({
|
|||
searchRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onOpen();
|
||||
}, [onOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(-1);
|
||||
}, [normalizedQuery]);
|
||||
|
|
@ -286,6 +329,8 @@ export const CodexDesktopModelsSettings = forwardRef<
|
|||
normalizedInitialSettings?.selected ?? [],
|
||||
);
|
||||
const [loading, setLoading] = useState(!initialSettings);
|
||||
const catalogRequested = useRef(false);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [launchAction, setLaunchAction] = useState<"start" | "restart">(
|
||||
|
|
@ -316,47 +361,79 @@ export const CodexDesktopModelsSettings = forwardRef<
|
|||
[],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!window.getCodexDesktopModelsSettings) {
|
||||
setError("ChatGPT model settings are unavailable in this Ollama build.");
|
||||
setWarning(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const request = ++statusRequestRef.current;
|
||||
try {
|
||||
const result = await window.getCodexDesktopModelsSettings();
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
applyResult(result, true);
|
||||
const refresh = useCallback(
|
||||
async (catalog = catalogRequested.current) => {
|
||||
const request = ++statusRequestRef.current;
|
||||
try {
|
||||
const summary = await getCodexDesktopModelsSettings(false);
|
||||
if (
|
||||
request !== statusRequestRef.current ||
|
||||
operationInFlightRef.current
|
||||
)
|
||||
return;
|
||||
const key = modelsCacheKey(summary.settings, accountKey);
|
||||
const cachedDefaults = summary.settings.usesDefaults
|
||||
? cachedDesktopModels<CodexDesktopModelsSettingsResult>(key)
|
||||
: undefined;
|
||||
// Let Settings render and accept input while the async catalog resolves
|
||||
// implicit defaults. Explicit saved choices are already in the summary.
|
||||
applyResult(cachedDefaults ?? summary, true);
|
||||
setLoading(false);
|
||||
const needsDefaults =
|
||||
summary.settings.usesDefaults && !summary.settings.selected?.length;
|
||||
if (!summary.settings.installed || (!catalog && !needsDefaults)) return;
|
||||
setCatalogLoading(true);
|
||||
const result = await desktopModels(key, (signal) =>
|
||||
getCodexDesktopModelsSettings(true, signal),
|
||||
);
|
||||
if (
|
||||
result.warning &&
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
await invalidateDesktopModels("chatgpt");
|
||||
}
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
applyResult(result, true);
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
setError("Ollama could not load the ChatGPT model settings.");
|
||||
setWarning(null);
|
||||
}
|
||||
} finally {
|
||||
if (request === statusRequestRef.current) {
|
||||
setLoading(false);
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
setError("Ollama could not load the ChatGPT model settings.");
|
||||
setWarning(null);
|
||||
}
|
||||
} finally {
|
||||
if (request === statusRequestRef.current) setLoading(false);
|
||||
}
|
||||
}, [applyResult]);
|
||||
},
|
||||
[accountKey, applyResult],
|
||||
);
|
||||
|
||||
const openCatalog = useCallback(() => {
|
||||
catalogRequested.current = true;
|
||||
setCatalogLoading(true);
|
||||
void refresh(true);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSettings) void refresh();
|
||||
if (!initialSettings || accountKeyRef.current !== accountKey)
|
||||
void refresh();
|
||||
accountKeyRef.current = accountKey;
|
||||
const onFocus = () => void refresh();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [initialSettings, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountKeyRef.current === accountKey) return;
|
||||
accountKeyRef.current = accountKey;
|
||||
void refresh();
|
||||
}, [accountKey, refresh]);
|
||||
return () => {
|
||||
++statusRequestRef.current;
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [accountKey, initialSettings, refresh]);
|
||||
|
||||
const hasChanges = !selectionsEqual(selected, saved);
|
||||
useEffect(() => {
|
||||
|
|
@ -402,22 +479,25 @@ export const CodexDesktopModelsSettings = forwardRef<
|
|||
setError("ChatGPT model settings are available in the Ollama macOS app.");
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
if (selected.length === 0 && (!settings?.usesDefaults || hasChanges)) {
|
||||
setError("Choose at least one model for ChatGPT.");
|
||||
return;
|
||||
}
|
||||
if (operationInFlightRef.current) return;
|
||||
|
||||
setApplying(true);
|
||||
setCatalogLoading(false);
|
||||
setLaunchAction(settings?.running ? "restart" : "start");
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("chatgpt");
|
||||
let result: CodexDesktopModelsSettingsResult | undefined;
|
||||
try {
|
||||
const modelsToApply =
|
||||
!hasChanges && settings?.usesDefaults ? [] : selected;
|
||||
let result = await window.applyCodexDesktopModels(modelsToApply, false);
|
||||
result = await window.applyCodexDesktopModels(modelsToApply, false);
|
||||
if (result.restartConfirmationRequired) {
|
||||
applyResult(result, true);
|
||||
if (
|
||||
|
|
@ -445,21 +525,23 @@ export const CodexDesktopModelsSettings = forwardRef<
|
|||
return;
|
||||
}
|
||||
if (openedStatus) {
|
||||
setSettings((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
installed: openedStatus.installed,
|
||||
connected: openedStatus.connected,
|
||||
running: openedStatus.running,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
result = {
|
||||
...result,
|
||||
settings: {
|
||||
...result.settings,
|
||||
installed: openedStatus.installed,
|
||||
connected: openedStatus.connected,
|
||||
running: openedStatus.running,
|
||||
},
|
||||
};
|
||||
setSettings(normalizeSettings(result.settings));
|
||||
}
|
||||
} catch {
|
||||
setError("Ollama could not apply the ChatGPT models.");
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("chatgpt");
|
||||
rememberDefaults(result, accountKey);
|
||||
operationInFlightRef.current = false;
|
||||
setApplying(false);
|
||||
}
|
||||
|
|
@ -475,12 +557,15 @@ export const CodexDesktopModelsSettings = forwardRef<
|
|||
}
|
||||
|
||||
setResetting(true);
|
||||
setCatalogLoading(false);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("chatgpt");
|
||||
let result: CodexDesktopModelsSettingsResult | undefined;
|
||||
try {
|
||||
const result = await resetModels();
|
||||
result = await resetModels();
|
||||
++statusRequestRef.current;
|
||||
if (result.error) {
|
||||
setSettings(normalizeSettings(result.settings));
|
||||
|
|
@ -495,10 +580,12 @@ export const CodexDesktopModelsSettings = forwardRef<
|
|||
return false;
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
await invalidateDesktopModels("chatgpt");
|
||||
rememberDefaults(result, accountKey);
|
||||
operationInFlightRef.current = false;
|
||||
setResetting(false);
|
||||
}
|
||||
}, [applyResult]);
|
||||
}, [accountKey, applyResult]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ resetToDefaults }), [resetToDefaults]);
|
||||
|
||||
|
|
@ -542,7 +629,12 @@ export const CodexDesktopModelsSettings = forwardRef<
|
|||
type="button"
|
||||
color="white"
|
||||
onClick={() => void applyChanges()}
|
||||
disabled={loading || busy || selected.length === 0}
|
||||
disabled={
|
||||
loading ||
|
||||
busy ||
|
||||
(selected.length === 0 &&
|
||||
(!settings?.usesDefaults || hasChanges))
|
||||
}
|
||||
>
|
||||
{applying && (
|
||||
<ArrowPathIcon data-slot="icon" className="animate-spin" />
|
||||
|
|
@ -602,7 +694,11 @@ export const CodexDesktopModelsSettings = forwardRef<
|
|||
))}
|
||||
{selected.length === 0 && (
|
||||
<span className="px-1 py-1 text-sm text-neutral-400">
|
||||
{loading ? "Loading models…" : "Select models"}
|
||||
{loading
|
||||
? "Loading models…"
|
||||
: settings?.usesDefaults && !hasChanges
|
||||
? "Recommended models"
|
||||
: "Select models"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -612,11 +708,17 @@ export const CodexDesktopModelsSettings = forwardRef<
|
|||
data-testid="chatgpt-model-options"
|
||||
className="z-50 flex w-[var(--button-width)] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 [--anchor-max-height:19rem] dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white"
|
||||
>
|
||||
{catalogLoading && (
|
||||
<p role="status" className="px-3 py-2 text-neutral-400">
|
||||
Loading models…
|
||||
</p>
|
||||
)}
|
||||
<ModelOptions
|
||||
models={models}
|
||||
selected={selected}
|
||||
maxModels={maxModels}
|
||||
onToggle={toggleModel}
|
||||
onOpen={openCatalog}
|
||||
/>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
|
|
|
|||
120
app/ui/app/src/components/DesktopModelPickers.browser.test.tsx
Normal file
120
app/ui/app/src/components/DesktopModelPickers.browser.test.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import {
|
||||
getClaudeDesktopAvailableModels,
|
||||
getClaudeDesktopModelsSettings,
|
||||
getCodexDesktopModelsSettings,
|
||||
} from "@/api";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import { page, userEvent } from "@vitest/browser/context";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { ClaudeDesktopModelsSettings } from "./ClaudeDesktopModelsSettings";
|
||||
import { CodexDesktopModelsSettings } from "./CodexDesktopModelsSettings";
|
||||
|
||||
vi.mock("@/api", () => ({
|
||||
getClaudeDesktopAvailableModels: vi.fn(),
|
||||
getClaudeDesktopModelsSettings: vi.fn(),
|
||||
getCodexDesktopModelsSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
const saved = {
|
||||
name: "saved-model",
|
||||
displayName: "saved-model",
|
||||
selected: true,
|
||||
availability: "unknown" as const,
|
||||
};
|
||||
const choices = [
|
||||
{ ...saved, availability: "available" as const },
|
||||
{
|
||||
name: "another-model",
|
||||
displayName: "another-model",
|
||||
selected: false,
|
||||
availability: "available" as const,
|
||||
},
|
||||
];
|
||||
vi.mocked(getCodexDesktopModelsSettings).mockImplementation(
|
||||
async (catalog) => ({
|
||||
settings: {
|
||||
supported: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
usesDefaults: false,
|
||||
maxModels: 5,
|
||||
selected: [saved.name],
|
||||
available: catalog ? choices.map((model) => model.name) : [],
|
||||
models: catalog ? choices : [saved],
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(getClaudeDesktopModelsSettings).mockImplementation(
|
||||
async (catalog) => ({
|
||||
supported: true,
|
||||
installed: true,
|
||||
used: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
autoMode: false,
|
||||
mappings: [
|
||||
{ routeId: "claude-fable-5", routeName: "Fable 5", model: saved.name },
|
||||
],
|
||||
models: catalog ? choices : [saved],
|
||||
}),
|
||||
);
|
||||
vi.mocked(getClaudeDesktopAvailableModels).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
queryClient.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ChatGPT", "{Enter}"],
|
||||
["ChatGPT", "[Space]"],
|
||||
["Claude", "{Enter}"],
|
||||
["Claude", "[Space]"],
|
||||
])("loads the real %s picker when opened with %s", async (integration, key) => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
integration === "ChatGPT" ? (
|
||||
<CodexDesktopModelsSettings />
|
||||
) : (
|
||||
<ClaudeDesktopModelsSettings />
|
||||
),
|
||||
),
|
||||
);
|
||||
const read =
|
||||
integration === "ChatGPT"
|
||||
? vi.mocked(getCodexDesktopModelsSettings)
|
||||
: vi.mocked(getClaudeDesktopModelsSettings);
|
||||
const button = page.getByRole("button", {
|
||||
name:
|
||||
integration === "ChatGPT"
|
||||
? "Add ChatGPT model"
|
||||
: "Ollama model for Fable 5",
|
||||
});
|
||||
await expect.element(button).toBeEnabled();
|
||||
expect(read.mock.calls.some(([catalog]) => catalog)).toBe(false);
|
||||
await act(async () => {
|
||||
(button.element() as HTMLButtonElement).focus();
|
||||
await userEvent.keyboard(key);
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole("option", { name: "another-model", exact: true }))
|
||||
.toBeEnabled();
|
||||
expect(read.mock.calls.filter(([catalog]) => catalog)).toHaveLength(1);
|
||||
});
|
||||
|
|
@ -70,9 +70,7 @@ function setClaudeConnection(
|
|||
}
|
||||
|
||||
function getClaudeConnectionSummary() {
|
||||
const getStatus =
|
||||
window.getClaudeDesktopConnectionSummary ?? window.getClaudeDesktopStatus;
|
||||
return getStatus?.() ?? Promise.resolve(null);
|
||||
return window.getClaudeDesktopConnectionSummary?.() ?? Promise.resolve(null);
|
||||
}
|
||||
|
||||
interface ScreenProps {
|
||||
|
|
@ -515,10 +513,7 @@ export function ConnectAppsScreen({
|
|||
|
||||
const refreshClaudeStatus = useCallback(async () => {
|
||||
if (isWindows) return null;
|
||||
if (
|
||||
!window.getClaudeDesktopConnectionSummary &&
|
||||
!window.getClaudeDesktopStatus
|
||||
) {
|
||||
if (!window.getClaudeDesktopConnectionSummary) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
|
|
@ -746,8 +741,7 @@ export function ConnectAppsScreen({
|
|||
const checkForInstall = async () => {
|
||||
if (checking) return;
|
||||
if (
|
||||
(!window.getClaudeDesktopConnectionSummary &&
|
||||
!window.getClaudeDesktopStatus) ||
|
||||
!window.getClaudeDesktopConnectionSummary ||
|
||||
!window.setClaudeDesktopConnected
|
||||
) {
|
||||
return;
|
||||
|
|
@ -840,8 +834,7 @@ export function ConnectAppsScreen({
|
|||
const connectClaude = async () => {
|
||||
if (claudePhase !== "idle") return;
|
||||
if (
|
||||
(!window.getClaudeDesktopConnectionSummary &&
|
||||
!window.getClaudeDesktopStatus) ||
|
||||
!window.getClaudeDesktopConnectionSummary ||
|
||||
!window.setClaudeDesktopConnected
|
||||
) {
|
||||
setClaudeError("Claude connection is available in the Ollama macOS app.");
|
||||
|
|
|
|||
|
|
@ -79,7 +79,9 @@ vi.mock("@tanstack/react-router", () => ({
|
|||
useBlocker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => ({
|
||||
QueryClient: (await importOriginal<typeof import("@tanstack/react-query")>())
|
||||
.QueryClient,
|
||||
useQueryClient: () => mocks.queryClient,
|
||||
useQuery: ({ queryKey }: { queryKey: string[] }) => {
|
||||
if (queryKey[0] === "settings") {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { Settings as SettingsType } from "@/gotypes";
|
|||
import { isWindowsPlatform } from "@/lib/platform";
|
||||
import { settingsMutationScope } from "@/lib/settingsMutationScope";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { invalidateDesktopModels } from "@/lib/desktopModels";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useBlocker } from "@tanstack/react-router";
|
||||
|
|
@ -163,7 +164,6 @@ export default function Settings() {
|
|||
isAuthenticated,
|
||||
refreshUser,
|
||||
isRefreshing,
|
||||
refetchUser,
|
||||
fetchConnectUrl,
|
||||
isLoading,
|
||||
disconnectUser,
|
||||
|
|
@ -267,6 +267,7 @@ export default function Settings() {
|
|||
},
|
||||
onSettled: (_status, _error, request) => {
|
||||
if (request.requestId !== latestCloudRequestId) return;
|
||||
void invalidateDesktopModels();
|
||||
queryClient.invalidateQueries({ queryKey: ["models"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cloudStatus"] });
|
||||
},
|
||||
|
|
@ -277,10 +278,6 @@ export default function Settings() {
|
|||
return updateCloudMutation.mutateAsync({ enabled, requestId });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refetchUser();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
window
|
||||
.getShowAppsInMenu?.()
|
||||
|
|
@ -792,6 +789,7 @@ export default function Settings() {
|
|||
</h2>
|
||||
<ClaudeDesktopModelsSettings
|
||||
ref={claudeModelsSettingsRef}
|
||||
accountKey={`${user?.id ?? "signed-out"}:${user?.plan ?? ""}:${cloudStatusKnown ? cloudDisabled : "unknown"}`}
|
||||
includeCloudModels={
|
||||
isAuthenticated && cloudStatusKnown && !cloudDisabled
|
||||
}
|
||||
|
|
|
|||
56
app/ui/app/src/hooks/useUser.test.tsx
Normal file
56
app/ui/app/src/hooks/useUser.test.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, create } from "react-test-renderer";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { fetchUser } from "@/api";
|
||||
import { useUser, userQueryOptions } from "./useUser";
|
||||
|
||||
vi.mock("@/api", () => ({
|
||||
fetchUser: vi.fn(),
|
||||
fetchConnectUrl: vi.fn(),
|
||||
disconnectUser: vi.fn(),
|
||||
}));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("shares startup account loading with Settings and reuses the fresh result", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
let resolve!: (value: null) => void;
|
||||
vi.mocked(fetchUser).mockImplementation(
|
||||
() =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
const client = new QueryClient();
|
||||
function Account() {
|
||||
const { isLoading } = useUser();
|
||||
return <span>{isLoading ? "Loading" : "Ready"}</span>;
|
||||
}
|
||||
const element = (
|
||||
<QueryClientProvider client={client}>
|
||||
<Account />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
const preload = client.prefetchQuery(userQueryOptions);
|
||||
await act(async () => {
|
||||
renderer = create(element);
|
||||
});
|
||||
expect(fetchUser).toHaveBeenCalledOnce();
|
||||
await act(async () => {
|
||||
resolve(null);
|
||||
await preload;
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer = create(element);
|
||||
});
|
||||
expect(fetchUser).toHaveBeenCalledOnce();
|
||||
expect(renderer!.root.findByType("span").children).toEqual(["Ready"]);
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
queryOptions,
|
||||
} from "@tanstack/react-query";
|
||||
import { fetchUser, fetchConnectUrl, disconnectUser } from "@/api";
|
||||
|
||||
export const userQueryOptions = queryOptions({
|
||||
queryKey: ["user"],
|
||||
queryFn: fetchUser,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
retry: 10,
|
||||
retryDelay: (attemptIndex: number) => Math.min(500 * attemptIndex, 2000),
|
||||
});
|
||||
|
||||
export function useUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const userQuery = useQuery({
|
||||
queryKey: ["user"],
|
||||
queryFn: async () => {
|
||||
const result = await fetchUser();
|
||||
return result;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Consider data stale after 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
|
||||
retry: 10,
|
||||
retryDelay: (attemptIndex) => Math.min(500 * attemptIndex, 2000),
|
||||
refetchOnMount: true, // Always fetch when component mounts
|
||||
});
|
||||
const userQuery = useQuery(userQueryOptions);
|
||||
|
||||
// Mutation to refresh user data
|
||||
const refreshUser = useMutation({
|
||||
|
|
|
|||
47
app/ui/app/src/lib/desktopModels.test.ts
Normal file
47
app/ui/app/src/lib/desktopModels.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { desktopModels, invalidateDesktopModels } from "./desktopModels";
|
||||
import { queryClient } from "./queryClient";
|
||||
|
||||
afterEach(() => queryClient.clear());
|
||||
|
||||
it("shares pending and recent inventory but separates accounts", async () => {
|
||||
let resolve!: (models: string[]) => void;
|
||||
const load = vi.fn(
|
||||
() =>
|
||||
new Promise<string[]>((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
const first = desktopModels(["chatgpt", "account-a"], load);
|
||||
const second = desktopModels(["chatgpt", "account-a"], load);
|
||||
resolve(["local-model"]);
|
||||
expect(await first).toEqual(["local-model"]);
|
||||
expect(await second).toEqual(["local-model"]);
|
||||
expect(await desktopModels(["chatgpt", "account-a"], load)).toEqual([
|
||||
"local-model",
|
||||
]);
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
const otherAccount = vi.fn().mockResolvedValue(["other-model"]);
|
||||
expect(await desktopModels(["chatgpt", "account-b"], otherAccount)).toEqual([
|
||||
"other-model",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not restore stale inventory after an action invalidates it", async () => {
|
||||
let resolve!: (models: string[]) => void;
|
||||
let signal!: AbortSignal;
|
||||
const pending = desktopModels(["claude"], (requestSignal) => {
|
||||
signal = requestSignal;
|
||||
return new Promise<string[]>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
});
|
||||
const canceled = expect(pending).rejects.toThrow();
|
||||
await invalidateDesktopModels();
|
||||
expect(signal.aborted).toBe(true);
|
||||
resolve(["old-model"]);
|
||||
await canceled;
|
||||
expect(await desktopModels(["claude"], async () => ["new-model"])).toEqual([
|
||||
"new-model",
|
||||
]);
|
||||
});
|
||||
35
app/ui/app/src/lib/desktopModels.ts
Normal file
35
app/ui/app/src/lib/desktopModels.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { queryClient } from "./queryClient";
|
||||
|
||||
queryClient.setQueryDefaults(["desktopModels"], {
|
||||
staleTime: 30_000,
|
||||
gcTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// One inventory per integration/account/saved state, independent of picker search.
|
||||
// Native actions still validate current model access before changing configuration.
|
||||
export function desktopModels<T>(
|
||||
key: readonly unknown[],
|
||||
load: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return queryClient.fetchQuery({
|
||||
queryKey: ["desktopModels", ...key],
|
||||
queryFn: ({ signal }) => load(signal),
|
||||
});
|
||||
}
|
||||
|
||||
export function cachedDesktopModels<T>(key: readonly unknown[]): T | undefined {
|
||||
return queryClient.getQueryData<T>(["desktopModels", ...key]);
|
||||
}
|
||||
|
||||
export function cacheDesktopModels<T>(key: readonly unknown[], models: T) {
|
||||
queryClient.setQueryData(["desktopModels", ...key], models);
|
||||
}
|
||||
|
||||
export async function invalidateDesktopModels(integration?: string) {
|
||||
const queryKey = integration
|
||||
? ["desktopModels", integration]
|
||||
: ["desktopModels"];
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
queryClient.removeQueries({ queryKey });
|
||||
}
|
||||
8
app/ui/app/src/lib/queryClient.ts
Normal file
8
app/ui/app/src/lib/queryClient.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { networkMode: "always" },
|
||||
queries: { networkMode: "always" },
|
||||
},
|
||||
});
|
||||
|
|
@ -1,27 +1,13 @@
|
|||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { fetchUser } from "./api";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { userQueryOptions } from "./hooks/useUser";
|
||||
import { StreamingProvider } from "./contexts/StreamingContext";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
networkMode: "always", // Run mutations regardless of network state
|
||||
},
|
||||
queries: {
|
||||
networkMode: "always", // Allow queries even when offline (local server)
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fetchUser().then((userData) => {
|
||||
if (userData) {
|
||||
queryClient.setQueryData(["user"], userData);
|
||||
}
|
||||
});
|
||||
void queryClient.prefetchQuery(userQueryOptions);
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
|
|
|
|||
2
app/ui/app/src/types/webview.d.ts
vendored
2
app/ui/app/src/types/webview.d.ts
vendored
|
|
@ -130,7 +130,6 @@ declare global {
|
|||
drag?: () => void;
|
||||
doubleClick?: () => void;
|
||||
activateOllama?: () => void;
|
||||
getClaudeDesktopStatus?: () => Promise<ClaudeDesktopStatus>;
|
||||
getClaudeDesktopConnectionSummary?: () => Promise<ClaudeDesktopStatus>;
|
||||
getClaudeDesktopRequestCount?: () => Promise<number>;
|
||||
setClaudeDesktopConnected?: (
|
||||
|
|
@ -147,7 +146,6 @@ declare global {
|
|||
restartConfirmed: boolean,
|
||||
) => Promise<CodexDesktopActionResult>;
|
||||
installCodexDesktop?: () => Promise<CodexDesktopInstallResult>;
|
||||
getCodexDesktopModelsSettings?: () => Promise<CodexDesktopModelsSettingsResult>;
|
||||
applyCodexDesktopModels?: (
|
||||
models: string[],
|
||||
restartConfirmed: boolean,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { defineConfig, mergeConfig } from "vite";
|
||||
import { configDefaults } from "vitest/config";
|
||||
import path from "path";
|
||||
import baseConfig from "./vite.config";
|
||||
|
||||
|
|
@ -15,6 +16,19 @@ export default defineConfig((configEnv) =>
|
|||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
...(configEnv.mode === "browser"
|
||||
? {
|
||||
include: ["src/**/*.browser.test.tsx"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: "playwright",
|
||||
instances: [{ browser: "chromium" }],
|
||||
headless: true,
|
||||
},
|
||||
}
|
||||
: {
|
||||
exclude: [...configDefaults.exclude, "**/*.browser.test.tsx"],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ type Server struct {
|
|||
Updater *updater.Updater
|
||||
UpdateAvailableFunc func()
|
||||
IntegrationInstalled func(string) bool
|
||||
IntegrationModels http.Handler
|
||||
ListCloudModels func(context.Context) (*api.ListResponse, error)
|
||||
}
|
||||
|
||||
|
|
@ -297,6 +298,12 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.Handle("POST /api/v1/cloud", handle(s.cloudSetting))
|
||||
mux.Handle("GET /api/v1/models/cloud", handle(s.getCloudModels))
|
||||
mux.Handle("GET /api/v1/integrations", handle(s.getIntegrationStatuses))
|
||||
if s.IntegrationModels != nil {
|
||||
mux.Handle("GET /api/v1/integrations/{integration}/models", handle(func(w http.ResponseWriter, r *http.Request) error {
|
||||
s.IntegrationModels.ServeHTTP(w, r)
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
// Ollama proxy endpoints
|
||||
ollamaProxy := s.ollamaProxy()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue