app: defer Settings model discovery (#18598)

This commit is contained in:
Parth Sareen 2026-09-24 13:47:43 -07:00 • committed by GitHub
parent 9eae4fac93
commit 8a5d443148
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1674 additions and 202 deletions

View file

@ -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("")
},

View file

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

View file

@ -10,9 +10,6 @@ import (
)
func bindClaudeDesktop(wv webview.WebView) {
wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus {
return getClaudeDesktopConnectionStatus()
})
wv.Bind("getClaudeDesktopConnectionSummary", func() claudeDesktopStatus {
return getClaudeDesktopConnectionSummary()
})

View file

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

View file

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

View 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
}

View 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)
}
}

View file

@ -0,0 +1,7 @@
//go:build windows
package main
import "net/http"
func desktopModelSettingsHandler() http.Handler { return nil }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);
});

View file

@ -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.");

View file

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

View file

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

View 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();
}
});

View file

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

View 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",
]);
});

View 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 });
}

View file

@ -0,0 +1,8 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
mutations: { networkMode: "always" },
queries: { networkMode: "always" },
},
});

View file

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

View file

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

View file

@ -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"],
}),
},
}),
),

View file

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