diff --git a/app/cmd/app/app.go b/app/cmd/app/app.go index a4a01ae6e..495e275ad 100644 --- a/app/cmd/app/app.go +++ b/app/cmd/app/app.go @@ -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("") }, diff --git a/app/cmd/app/app_darwin.go b/app/cmd/app/app_darwin.go index 7beb49113..07cbace8b 100644 --- a/app/cmd/app/app_darwin.go +++ b/app/cmd/app/app_darwin.go @@ -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) } diff --git a/app/cmd/app/claude_desktop_bindings_darwin.go b/app/cmd/app/claude_desktop_bindings_darwin.go index db21a272d..cf6de8c9f 100644 --- a/app/cmd/app/claude_desktop_bindings_darwin.go +++ b/app/cmd/app/claude_desktop_bindings_darwin.go @@ -10,9 +10,6 @@ import ( ) func bindClaudeDesktop(wv webview.WebView) { - wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus { - return getClaudeDesktopConnectionStatus() - }) wv.Bind("getClaudeDesktopConnectionSummary", func() claudeDesktopStatus { return getClaudeDesktopConnectionSummary() }) diff --git a/app/cmd/app/codex_app_bindings_darwin.go b/app/cmd/app/codex_app_bindings_darwin.go index 4eef46f54..f668fbe3e 100644 --- a/app/cmd/app/codex_app_bindings_darwin.go +++ b/app/cmd/app/codex_app_bindings_darwin.go @@ -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() diff --git a/app/cmd/app/codex_app_darwin.go b/app/cmd/app/codex_app_darwin.go index 8500091d2..42494c28b 100644 --- a/app/cmd/app/codex_app_darwin.go +++ b/app/cmd/app/codex_app_darwin.go @@ -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 diff --git a/app/cmd/app/desktop_models_darwin.go b/app/cmd/app/desktop_models_darwin.go new file mode 100644 index 000000000..eb0ee8f56 --- /dev/null +++ b/app/cmd/app/desktop_models_darwin.go @@ -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 +} diff --git a/app/cmd/app/desktop_models_darwin_test.go b/app/cmd/app/desktop_models_darwin_test.go new file mode 100644 index 000000000..67f0d5fad --- /dev/null +++ b/app/cmd/app/desktop_models_darwin_test.go @@ -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) + } +} diff --git a/app/cmd/app/desktop_models_windows.go b/app/cmd/app/desktop_models_windows.go new file mode 100644 index 000000000..2b21b403e --- /dev/null +++ b/app/cmd/app/desktop_models_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +import "net/http" + +func desktopModelSettingsHandler() http.Handler { return nil } diff --git a/app/ui/app/package.json b/app/ui/app/package.json index 89a205b5a..de616085d 100644 --- a/app/ui/app/package.json +++ b/app/ui/app/package.json @@ -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" }, diff --git a/app/ui/app/src/api.test.ts b/app/ui/app/src/api.test.ts index db7967a34..7a79aa754 100644 --- a/app/ui/app/src/api.test.ts +++ b/app/ui/app/src/api.test.ts @@ -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(); diff --git a/app/ui/app/src/api.ts b/app/ui/app/src/api.ts index e6390528a..da900225a 100644 --- a/app/ui/app/src/api.ts +++ b/app/ui/app/src/api.ts @@ -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 { } return response.json(); } + +async function getDesktopModelSettings( + integration: string, + catalog: boolean, + signal?: AbortSignal, +): Promise { + 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( + "claude-desktop", + catalog, + signal, + ); +} + +export function getCodexDesktopModelsSettings( + catalog: boolean, + signal?: AbortSignal, +) { + return getDesktopModelSettings( + "chatgpt", + catalog, + signal, + ); +} + // Helper function to convert Uint8Array to base64 function uint8ArrayToBase64(uint8Array: Uint8Array): string { const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow diff --git a/app/ui/app/src/components/ClaudeDesktopModelsSettings.interaction.test.tsx b/app/ui/app/src/components/ClaudeDesktopModelsSettings.interaction.test.tsx index 090d92fb0..a026efba5 100644 --- a/app/ui/app/src/components/ClaudeDesktopModelsSettings.interaction.test.tsx +++ b/app/ui/app/src/components/ClaudeDesktopModelsSettings.interaction.test.tsx @@ -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(); @@ -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((resolve, reject) => { + resolveCatalog = resolve; + rejectCatalog = reject; + }); + let resolveInventory!: (models: Model[]) => void; + let rejectInventory!: (error: Error) => void; + const pendingInventory = new Promise((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( + , + ); + }); + 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( + , + ); + }); + 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( + , + ); + }); + 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) => void; + let finishLatest!: (status: ReturnType) => void; + const first = new Promise>( + (resolve) => (finishFirst = resolve), + ); + const latest = new Promise>( + (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( + , + ); + }); + 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, diff --git a/app/ui/app/src/components/ClaudeDesktopModelsSettings.tsx b/app/ui/app/src/components/ClaudeDesktopModelsSettings.tsx index c36308962..19ddb5ed7 100644 --- a/app/ui/app/src/components/ClaudeDesktopModelsSettings.tsx +++ b/app/ui/app/src/components/ClaudeDesktopModelsSettings.tsx @@ -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 ( @@ -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 }) => ( - { - onChange(model); - close(); - }} - /> + <> + {loading && ( +

+ Loading models… +

+ )} + { + onChange(model); + close(); + }} + /> + )}
@@ -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(null); @@ -204,6 +222,10 @@ function ClaudeModelPickerOptions({ searchRef.current?.focus({ preventScroll: true }); }, []); + useEffect(() => { + onOpen(); + }, [onOpen]); + return ( <>
@@ -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( initialCloudModels ?? [], ); - const [modelsLoading, setModelsLoading] = useState(false); + const catalogRequested = useRef(false); + const [catalogLoading, setCatalogLoading] = useState(false); const [error, setError] = useState(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) diff --git a/app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx b/app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx index fb75409b6..3f28549bb 100644 --- a/app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx +++ b/app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx @@ -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(); @@ -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((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 | undefined; + try { + await act(async () => { + renderer = create(); + }); + 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) { + 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) { + 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 | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + 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 | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + 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 | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + 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 | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + 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( + , + ); + }); + 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 | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + 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( + , + ); + }); + 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 | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + 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(); + let renderer: ReturnType | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + 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( + , + ); + }); + 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 | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + 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()); + } + }); +}); diff --git a/app/ui/app/src/components/CodexDesktopModelsSettings.tsx b/app/ui/app/src/components/CodexDesktopModelsSettings.tsx index 678d0e67b..1d6087415 100644 --- a/app/ui/app/src/components/CodexDesktopModelsSettings.tsx +++ b/app/ui/app/src/components/CodexDesktopModelsSettings.tsx @@ -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(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 && ( @@ -602,7 +694,11 @@ export const CodexDesktopModelsSettings = forwardRef< ))} {selected.length === 0 && ( - {loading ? "Loading models…" : "Select models"} + {loading + ? "Loading models…" + : settings?.usesDefaults && !hasChanges + ? "Recommended models" + : "Select models"} )}
@@ -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 && ( +

+ Loading models… +

+ )} diff --git a/app/ui/app/src/components/DesktopModelPickers.browser.test.tsx b/app/ui/app/src/components/DesktopModelPickers.browser.test.tsx new file mode 100644 index 000000000..b7bec124b --- /dev/null +++ b/app/ui/app/src/components/DesktopModelPickers.browser.test.tsx @@ -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" ? ( + + ) : ( + + ), + ), + ); + 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); +}); diff --git a/app/ui/app/src/components/Onboarding.tsx b/app/ui/app/src/components/Onboarding.tsx index 00f055387..43f96c960 100644 --- a/app/ui/app/src/components/Onboarding.tsx +++ b/app/ui/app/src/components/Onboarding.tsx @@ -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."); diff --git a/app/ui/app/src/components/Settings.interaction.test.tsx b/app/ui/app/src/components/Settings.interaction.test.tsx index 7479fdddd..d5fb5e2d7 100644 --- a/app/ui/app/src/components/Settings.interaction.test.tsx +++ b/app/ui/app/src/components/Settings.interaction.test.tsx @@ -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()) + .QueryClient, useQueryClient: () => mocks.queryClient, useQuery: ({ queryKey }: { queryKey: string[] }) => { if (queryKey[0] === "settings") { diff --git a/app/ui/app/src/components/Settings.tsx b/app/ui/app/src/components/Settings.tsx index 1060136b3..371385ccf 100644 --- a/app/ui/app/src/components/Settings.tsx +++ b/app/ui/app/src/components/Settings.tsx @@ -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() { ({ + 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 {isLoading ? "Loading" : "Ready"}; + } + const element = ( + + + + ); + let renderer: ReturnType | 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(); + } +}); diff --git a/app/ui/app/src/hooks/useUser.ts b/app/ui/app/src/hooks/useUser.ts index a9541f3ca..b2058150f 100644 --- a/app/ui/app/src/hooks/useUser.ts +++ b/app/ui/app/src/hooks/useUser.ts @@ -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({ diff --git a/app/ui/app/src/lib/desktopModels.test.ts b/app/ui/app/src/lib/desktopModels.test.ts new file mode 100644 index 000000000..425515b5d --- /dev/null +++ b/app/ui/app/src/lib/desktopModels.test.ts @@ -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((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((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", + ]); +}); diff --git a/app/ui/app/src/lib/desktopModels.ts b/app/ui/app/src/lib/desktopModels.ts new file mode 100644 index 000000000..068c3a4f3 --- /dev/null +++ b/app/ui/app/src/lib/desktopModels.ts @@ -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( + key: readonly unknown[], + load: (signal: AbortSignal) => Promise, +): Promise { + return queryClient.fetchQuery({ + queryKey: ["desktopModels", ...key], + queryFn: ({ signal }) => load(signal), + }); +} + +export function cachedDesktopModels(key: readonly unknown[]): T | undefined { + return queryClient.getQueryData(["desktopModels", ...key]); +} + +export function cacheDesktopModels(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 }); +} diff --git a/app/ui/app/src/lib/queryClient.ts b/app/ui/app/src/lib/queryClient.ts new file mode 100644 index 000000000..10cc9a594 --- /dev/null +++ b/app/ui/app/src/lib/queryClient.ts @@ -0,0 +1,8 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + mutations: { networkMode: "always" }, + queries: { networkMode: "always" }, + }, +}); diff --git a/app/ui/app/src/main.tsx b/app/ui/app/src/main.tsx index 3e325a3ca..944dba87c 100644 --- a/app/ui/app/src/main.tsx +++ b/app/ui/app/src/main.tsx @@ -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, diff --git a/app/ui/app/src/types/webview.d.ts b/app/ui/app/src/types/webview.d.ts index e6da14252..edc73242b 100644 --- a/app/ui/app/src/types/webview.d.ts +++ b/app/ui/app/src/types/webview.d.ts @@ -130,7 +130,6 @@ declare global { drag?: () => void; doubleClick?: () => void; activateOllama?: () => void; - getClaudeDesktopStatus?: () => Promise; getClaudeDesktopConnectionSummary?: () => Promise; getClaudeDesktopRequestCount?: () => Promise; setClaudeDesktopConnected?: ( @@ -147,7 +146,6 @@ declare global { restartConfirmed: boolean, ) => Promise; installCodexDesktop?: () => Promise; - getCodexDesktopModelsSettings?: () => Promise; applyCodexDesktopModels?: ( models: string[], restartConfirmed: boolean, diff --git a/app/ui/app/vitest.config.ts b/app/ui/app/vitest.config.ts index 0a4337d8e..6b63fe0ac 100644 --- a/app/ui/app/vitest.config.ts +++ b/app/ui/app/vitest.config.ts @@ -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"], + }), }, }), ), diff --git a/app/ui/ui.go b/app/ui/ui.go index 4be03c532..1f7287545 100644 --- a/app/ui/ui.go +++ b/app/ui/ui.go @@ -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()