app: add a first-use ChatGPT connection intro (#18321)

This commit is contained in:
Eva H
2026-09-08 15:44:21 -07:00
committed by GitHub
parent 3e02feac8d
commit 9160b3c0b8
16 changed files with 1781 additions and 145 deletions
+6
View File
@@ -17,6 +17,12 @@ func codexDesktopModelRefreshError(settings codexDesktopModelsSettings) string {
}
func bindCodexDesktop(wv webview.WebView) {
wv.Bind("markCodexDesktopIntegrationUsed", func() string {
if err := markCodexDesktopIntegrationUsed(); err != nil {
return err.Error()
}
return ""
})
wv.Bind("getCodexDesktopStatus", func() codexDesktopStatus {
return getCodexDesktopStatus()
})
+22
View File
@@ -62,6 +62,7 @@ var (
)
type codexDesktopStatus struct {
Used bool `json:"used"`
Supported bool `json:"supported"`
Installed bool `json:"installed"`
Connected bool `json:"connected"`
@@ -149,6 +150,7 @@ func getCodexDesktopStatus() codexDesktopStatus {
model = models[0]
}
return codexDesktopStatus{
Used: hasUsedCodexDesktopIntegration(),
Supported: true,
Installed: codexDesktop.Installed(),
Connected: connected,
@@ -977,3 +979,23 @@ func codexDesktopCloudModel(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
return strings.HasSuffix(name, ":cloud") || strings.HasSuffix(name, "-cloud")
}
func hasUsedCodexDesktopIntegration() bool {
if appStore == nil {
return false
}
settings, err := appStore.Settings()
if err != nil {
return false
}
return settings.CodexDesktopUsed
}
func markCodexDesktopIntegrationUsed() error {
codexDesktopMu.Lock()
defer codexDesktopMu.Unlock()
if appStore == nil {
return errors.New("settings are unavailable")
}
return appStore.MarkCodexDesktopUsed()
}
+47
View File
@@ -9,12 +9,14 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"slices"
"strings"
"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"
@@ -1377,3 +1379,48 @@ func TestCodexDesktopModelRefreshErrorUsesUserFacingCopy(t *testing.T) {
t.Fatalf("empty-selection message = %q", withoutSavedModels)
}
}
func TestCodexDesktopUsed(t *testing.T) {
previous := appStore
t.Cleanup(func() { appStore = previous })
path := filepath.Join(t.TempDir(), "db.sqlite")
appStore = &store.Store{DBPath: path}
if hasUsedCodexDesktopIntegration() {
t.Fatal("new store already acknowledged")
}
settings, err := appStore.Settings()
if err != nil {
t.Fatal(err)
}
settings.ClaudeDesktopUsed = true
if err := appStore.SetSettings(settings); err != nil {
t.Fatal(err)
}
if err := markCodexDesktopIntegrationUsed(); err != nil {
t.Fatal(err)
}
if err := appStore.Close(); err != nil {
t.Fatal(err)
}
appStore = &store.Store{DBPath: path}
defer appStore.Close()
if !hasUsedCodexDesktopIntegration() {
t.Fatal("acknowledgment did not survive reopening the store")
}
settings, err = appStore.Settings()
if err != nil {
t.Fatal(err)
}
if !settings.ClaudeDesktopUsed {
t.Fatal("changed Claude history")
}
}
func TestCodexDesktopUsedUnavailable(t *testing.T) {
previous := appStore
t.Cleanup(func() { appStore = previous })
appStore = nil
if err := markCodexDesktopIntegrationUsed(); err == nil {
t.Fatal("expected unavailable store error")
}
}
+24 -3
View File
@@ -14,7 +14,7 @@ import (
// currentSchemaVersion defines the current database schema version.
// Increment this when making schema changes that require migrations.
const currentSchemaVersion = 18
const currentSchemaVersion = 19
// database wraps the SQLite connection.
// SQLite handles its own locking for concurrent access:
@@ -90,6 +90,7 @@ func (db *database) init() error {
remote TEXT NOT NULL DEFAULT '', -- deprecated
auto_update_enabled BOOLEAN NOT NULL DEFAULT 1,
claude_desktop_used BOOLEAN NOT NULL DEFAULT 0,
codex_desktop_used BOOLEAN NOT NULL DEFAULT 0,
schema_version INTEGER NOT NULL DEFAULT %d
);
@@ -285,6 +286,11 @@ func (db *database) migrate() error {
return fmt.Errorf("migrate v17 to v18: %w", err)
}
version = 18
case 18:
if err := db.migrateV18ToV19(); err != nil {
return fmt.Errorf("migrate v18 to v19: %w", err)
}
version = 19
default:
// If we have a version we don't recognize, just set it to current
// This might happen during development
@@ -586,6 +592,16 @@ func (db *database) migrateV17ToV18() error {
return nil
}
// migrateV18ToV19 records successful ChatGPT integration use.
func (db *database) migrateV18ToV19() error {
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN codex_desktop_used BOOLEAN NOT NULL DEFAULT 0`)
if err != nil && !duplicateColumnError(err) {
return fmt.Errorf("add codex_desktop_used column: %w", err)
}
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 19`)
return err
}
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
func (db *database) cleanupOrphanedData() error {
_, err := db.conn.Exec(`
@@ -1234,9 +1250,9 @@ func (db *database) getSettings() (Settings, error) {
var s Settings
err := db.conn.QueryRow(`
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, onboarding_version, think_enabled, think_level, auto_update_enabled, claude_desktop_used
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, onboarding_version, think_enabled, think_level, auto_update_enabled, claude_desktop_used, codex_desktop_used
FROM settings
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled, &s.ClaudeDesktopUsed)
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled, &s.ClaudeDesktopUsed, &s.CodexDesktopUsed)
if err != nil {
return Settings{}, fmt.Errorf("get settings: %w", err)
}
@@ -1260,6 +1276,11 @@ func (db *database) setSettings(s Settings) error {
return nil
}
func (db *database) markCodexDesktopUsed() error {
_, err := db.conn.Exec(`UPDATE settings SET codex_desktop_used = 1`)
return err
}
func (db *database) isCloudSettingMigrated() (bool, error) {
var migrated bool
err := db.conn.QueryRow("SELECT cloud_setting_migrated FROM settings").Scan(&migrated)
+35
View File
@@ -563,3 +563,38 @@ func loadV2Schema(t *testing.T, dbPath string) *database {
return &database{conn: conn}
}
func TestCodexDesktopUsedMigration(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "codex-intro.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
settings, err := db.getSettings()
if err != nil {
t.Fatalf("failed to read settings: %v", err)
}
if settings.CodexDesktopUsed {
t.Fatal("expected fresh installs to have no ChatGPT intro acknowledgment")
}
if _, err := db.conn.Exec(`
ALTER TABLE settings DROP COLUMN codex_desktop_used;
UPDATE settings SET schema_version = 18;
`); err != nil {
t.Fatalf("failed to seed v18 settings row: %v", err)
}
if err := db.migrate(); err != nil {
t.Fatalf("migration from v18 to v19 failed: %v", err)
}
settings, err = db.getSettings()
if err != nil {
t.Fatalf("failed to read migrated settings: %v", err)
}
if settings.CodexDesktopUsed {
t.Fatal("expected existing installs to start with no inferred ChatGPT intro acknowledgment")
}
}
+11
View File
@@ -178,6 +178,10 @@ type Settings struct {
// ClaudeDesktopUsed records whether Claude Desktop has ever been connected through Ollama.
ClaudeDesktopUsed bool
// CodexDesktopUsed records whether ChatGPT has successfully connected through Ollama.
// Only MarkCodexDesktopUsed updates it; SetSettings preserves the stored value.
CodexDesktopUsed bool
}
// Keep in sync with CURRENT_ONBOARDING_VERSION in app/ui/app/src/lib/onboarding.ts.
@@ -426,6 +430,13 @@ func (s *Store) SetSettings(settings Settings) error {
return s.db.setSettings(settings)
}
func (s *Store) MarkCodexDesktopUsed() error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.markCodexDesktopUsed()
}
func (s *Store) Chats() ([]Chat, error) {
if err := s.ensureDB(); err != nil {
return nil, err
+55
View File
@@ -279,6 +279,61 @@ func TestClaudeDesktopUsedRoundTrip(t *testing.T) {
}
}
func TestCodexDesktopUsedPreservedBySettings(t *testing.T) {
s, cleanup := setupTestStore(t)
defer cleanup()
settings, err := s.Settings()
if err != nil {
t.Fatal(err)
}
settings.Browser = true
settings.ClaudeDesktopUsed = true
settings.CodexDesktopUsed = true
if err := s.SetSettings(settings); err != nil {
t.Fatal(err)
}
saved, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if saved.CodexDesktopUsed {
t.Fatal("ordinary settings save acknowledged the intro")
}
settings.CodexDesktopUsed = false
if saved != settings {
t.Fatal("ordinary settings save lost unrelated settings")
}
for range 2 {
if err := s.MarkCodexDesktopUsed(); err != nil {
t.Fatal(err)
}
}
saved, err = s.Settings()
if err != nil {
t.Fatal(err)
}
want := settings
want.CodexDesktopUsed = true
if saved != want {
t.Fatal("acknowledgment did not preserve unrelated settings")
}
settings.Browser = false
if err := s.SetSettings(settings); err != nil {
t.Fatal(err)
}
saved, err = s.Settings()
if err != nil {
t.Fatal(err)
}
want.Browser = false
if saved != want {
t.Fatal("stale settings save lost acknowledgment or the requested setting")
}
}
// setupTestStore creates a temporary store for testing
func setupTestStore(t *testing.T) (*Store, func()) {
t.Helper()
Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { act, create } from "react-test-renderer";
import { afterEach, expect, it, vi } from "vitest";
import { CodexConnectedIntro } from "./CodexConnectedIntro";
vi.mock("@headlessui/react", () => {
const Container = ({ children }: { children: ReactNode }) => (
<div>{children}</div>
);
return {
Dialog: Container,
DialogPanel: Container,
DialogTitle: Container,
Description: Container,
};
});
afterEach(() => vi.unstubAllGlobals());
it("hands Continue to the connection flow, like Claude's intro", async () => {
const done = vi.fn();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer;
try {
await act(async () => {
renderer = create(<CodexConnectedIntro onDone={done} />);
});
expect(done).not.toHaveBeenCalled();
const button = renderer!.root.findByType("button");
expect(button.children).toEqual(["Continue"]);
await act(async () => button.props.onClick());
expect(done).toHaveBeenCalledOnce();
} finally {
await act(async () => renderer?.unmount());
}
});
@@ -0,0 +1,48 @@
import {
Dialog,
DialogPanel,
DialogTitle,
Description,
} from "@headlessui/react";
export function CodexConnectedIntro({ onDone }: { onDone: () => void }) {
return (
<Dialog open onClose={() => {}} className="relative z-50">
<div
className="claude-connected-backdrop fixed inset-0 bg-black/20 dark:bg-black/50"
aria-hidden="true"
/>
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto p-6">
<DialogPanel className="claude-connected-dialog relative max-h-full w-full max-w-md overflow-y-auto rounded-2xl bg-white font-sans shadow-2xl ring-1 ring-black/10 dark:bg-neutral-800 dark:ring-white/10">
<img
src="/chatgpt-connected.png"
alt="Ollama models alongside OpenAI models in the ChatGPT Codex model picker"
width={1172}
height={1084}
className="h-auto w-full object-contain"
draggable={false}
/>
<div className="p-6">
<DialogTitle className="font-rounded text-lg font-medium leading-6 text-neutral-950 dark:text-neutral-100">
Use Ollama models in ChatGPT
</DialogTitle>
<Description className="mt-2 text-[13px] leading-5 text-neutral-500 dark:text-neutral-400">
Click Continue to open ChatGPT. In Codex mode, choose an Ollama
model from the model picker for your task.
</Description>
<div className="mt-5 flex justify-end">
<button
type="button"
data-autofocus
onClick={onDone}
className="rounded-full bg-neutral-100 px-6 py-2 text-sm font-normal text-neutral-950 transition-colors hover:bg-neutral-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 dark:bg-white dark:hover:bg-neutral-100"
>
Continue
</button>
</div>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+254 -98
View File
@@ -1,21 +1,47 @@
import { CodexConnectedIntro } from "./CodexConnectedIntro";
import type { IntegrationStatus } from "@/api";
import { INTEGRATION_ICONS } from "@/lib/launchCommands";
import type {
CodexDesktopActionResult,
CodexDesktopInstallResult,
CodexDesktopStatus,
} from "@/types/webview";
import { ArrowPathIcon, CommandLineIcon } from "@heroicons/react/24/outline";
import {
useMutation,
useMutationState,
useQueryClient,
} from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
export const CODEX_DESKTOP_INSTALL_TIMEOUT_MS = 120_000;
const acknowledgmentKey = ["codex-desktop-acknowledgment"];
type CodexConnectPhase =
| "idle"
| "installing"
| "waiting-for-install"
| "connecting"
| "disconnecting";
const connectionProgress = {
idle: null,
installing: {
label: "Downloading…",
description: "Ollama is downloading the ChatGPT installer…",
},
"waiting-for-install": {
label: "Finish installing…",
description:
"Finish installing ChatGPT. Ollama will connect it automatically.",
},
connecting: {
label: "Connecting…",
description: "Connecting ChatGPT to Ollama…",
},
saving: {
label: "Saving…",
description: "Saving your progress…",
},
disconnecting: {
label: "Disconnecting…",
description: "Restoring ChatGPTs usual connection…",
},
} as const;
type CodexConnectPhase = keyof typeof connectionProgress;
interface CodexDesktopRowProps {
integration: IntegrationStatus;
@@ -67,8 +93,64 @@ export function CodexDesktopRow({
const [phase, setPhase] = useState<CodexConnectPhase>("idle");
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [showIntro, setShowIntro] = useState(false);
const introRestartConfirmed = useRef(false);
const used = useRef(false);
const mounted = useRef(true);
const operationInFlight = useRef(false);
const statusRequest = useRef(0);
const queryClient = useQueryClient();
const acknowledgment = useMutation({
mutationKey: acknowledgmentKey,
// Keep the save and its retry available across Apps page navigation.
gcTime: Infinity,
retry: false,
networkMode: "always",
mutationFn: async () => {
if (!window.markCodexDesktopIntegrationUsed)
throw new Error("Acknowledgment is unavailable");
const saveError = await window.markCodexDesktopIntegrationUsed();
if (saveError) throw new Error(saveError);
},
});
const acknowledgmentStates = useMutationState({
filters: { mutationKey: acknowledgmentKey, exact: true },
select: (mutation) => mutation.state.status,
});
const acknowledgmentStatus =
acknowledgmentStates[acknowledgmentStates.length - 1];
const savingAcknowledgment = acknowledgmentStates.includes("pending");
const acknowledgmentFailed =
acknowledgmentStates.includes("error") &&
acknowledgmentStatus !== "success" &&
!status?.used &&
!used.current;
const beginOperation = useCallback(
(nextPhase: CodexConnectPhase) => {
if (
!mounted.current ||
operationInFlight.current ||
queryClient.isMutating({ mutationKey: acknowledgmentKey })
)
return false;
operationInFlight.current = true;
++statusRequest.current;
setPhase(nextPhase);
setError(null);
setNotice(null);
return true;
},
[queryClient],
);
const finishOperation = useCallback(
(nextPhase: CodexConnectPhase = "idle") => {
operationInFlight.current = false;
if (mounted.current) setPhase(nextPhase);
},
[],
);
useEffect(() => {
mounted.current = true;
@@ -77,15 +159,33 @@ export function CodexDesktopRow({
};
}, []);
useEffect(() => {
if (acknowledgmentStatus !== "success") return;
used.current = true;
setStatus((current) =>
current && !current.used ? { ...current, used: true } : current,
);
}, [acknowledgmentStatus]);
const refreshStatus = useCallback(async () => {
if (operationInFlight.current || !window.getCodexDesktopStatus) return;
const request = ++statusRequest.current;
const isCurrent = () =>
mounted.current &&
request === statusRequest.current &&
!operationInFlight.current;
try {
const next = await window.getCodexDesktopStatus();
if (!isCurrent()) return;
setStatus(next);
if (next.used) {
used.current = true;
}
setError(null);
setNotice(null);
} catch {
setError("Ollama could not read the ChatGPT connection status.");
if (isCurrent())
setError("Ollama could not read the ChatGPT connection status.");
}
}, []);
@@ -137,6 +237,7 @@ export function CodexDesktopRow({
!active ||
checking ||
completing ||
operationInFlight.current ||
!window.getCodexDesktopStatus ||
!window.setCodexDesktopConnected
) {
@@ -148,17 +249,22 @@ export function CodexDesktopRow({
if (!active || !mounted.current) return;
setStatus(next);
if (!next.installed) return;
if (!beginOperation("connecting")) return;
completing = true;
if (next.running) {
setPhase("idle");
setError(
"ChatGPT is installed. Turn on the switch to restart it with Ollama models.",
);
return;
}
setPhase("connecting");
if (!next.used && !used.current) {
introRestartConfirmed.current = false;
setShowIntro(true);
return;
}
const result = await window.setCodexDesktopConnected(true, false);
if (!mounted.current) return;
setStatus(result.status);
@@ -173,13 +279,13 @@ export function CodexDesktopRow({
} else {
setNotice("Ollama models added alongside Codex models");
}
setPhase("idle");
} catch {
if (!mounted.current) return;
if (!mounted.current || (!active && !completing)) return;
setPhase("idle");
setError("Ollama could not finish connecting ChatGPT.");
} finally {
checking = false;
if (completing) finishOperation();
}
};
@@ -187,6 +293,7 @@ export function CodexDesktopRow({
const interval = window.setInterval(checkForInstall, 1000);
const timeout = window.setTimeout(() => {
if (!active || completing) return;
active = false;
setPhase("idle");
setError("ChatGPT installation wasnt detected. Try again.");
}, CODEX_DESKTOP_INSTALL_TIMEOUT_MS);
@@ -195,90 +302,120 @@ export function CodexDesktopRow({
window.clearInterval(interval);
window.clearTimeout(timeout);
};
}, [phase]);
}, [phase, beginOperation, finishOperation]);
const connected = status?.connected ?? false;
const installed = status?.installed ?? integration.installed ?? false;
const pending = phase !== "idle";
const pending = phase !== "idle" || savingAcknowledgment;
const displayedConnected =
phase === "disconnecting"
? false
: connected ||
showIntro ||
phase === "installing" ||
phase === "waiting-for-install" ||
phase === "connecting";
const isConnecting = phase !== "idle";
const progress = connectionProgress[savingAcknowledgment ? "saving" : phase];
const statusLabel =
phase === "installing"
? "Downloading…"
: phase === "waiting-for-install"
? "Finish installing…"
: phase === "connecting"
? "Connecting…"
: phase === "disconnecting"
? "Disconnecting…"
: !connected && !installed
? "Download & connect"
: null;
const description =
progress?.label ?? (!connected && !installed ? "Download & connect" : null);
const actionError =
error ??
(acknowledgmentFailed
? "Ollama couldnt save your progress. Please try again."
: null);
const description =
actionError ??
notice ??
(phase === "installing"
? "Ollama is downloading the ChatGPT installer…"
: phase === "waiting-for-install"
? "Finish installing ChatGPT. Ollama will connect it automatically."
: phase === "connecting"
? "Connecting ChatGPT to Ollama…"
: phase === "disconnecting"
? "Restoring ChatGPTs usual connection…"
: codexDesktopDescription(status, integration.description));
progress?.description ??
codexDesktopDescription(status, integration.description);
const toggleConnection = async () => {
if (pending || operationInFlight.current) return;
if (!window.setCodexDesktopConnected) {
setError("The ChatGPT integration is unavailable.");
return;
}
const enabled = !connected;
if (enabled && !installed) {
if (!window.installCodexDesktop || !window.getCodexDesktopStatus) {
setError("Ollama could not install ChatGPT.");
return;
}
setPhase("installing");
setError(null);
setNotice(null);
let installResult: CodexDesktopInstallResult = "failed";
try {
installResult = await window.installCodexDesktop();
} catch {
// The shared failure message below covers a rejected native request.
}
if (installResult === "cancelled") {
setPhase("idle");
return;
}
if (installResult !== "opened") {
setPhase("idle");
setError("Ollama could not install ChatGPT.");
return;
}
setPhase("waiting-for-install");
return;
}
const nextPhase = enabled ? "connecting" : "disconnecting";
operationInFlight.current = true;
setPhase(nextPhase);
setError(null);
setNotice(null);
const saveAcknowledgment = async (): Promise<boolean> => {
if (queryClient.isMutating({ mutationKey: acknowledgmentKey }))
return false;
try {
await acknowledgment.mutateAsync();
used.current = true;
if (mounted.current) {
setStatus((current) =>
current ? { ...current, used: true } : current,
);
}
return true;
} catch {
return false;
}
};
const retryAcknowledgment = async () => {
if (pending || !acknowledgmentFailed || !beginOperation("saving")) return;
try {
await saveAcknowledgment();
} finally {
finishOperation();
if (mounted.current) void refreshStatus();
}
};
const toggleConnection = async (fromIntro = false) => {
const enabled = fromIntro || !connected;
const nextPhase = enabled
? installed
? "connecting"
: "installing"
: "disconnecting";
if (pending || (showIntro && !fromIntro) || !beginOperation(nextPhase))
return;
let finalPhase: CodexConnectPhase = "idle";
let restartConfirmed = fromIntro && introRestartConfirmed.current;
if (fromIntro) {
setShowIntro(false);
introRestartConfirmed.current = false;
}
try {
if (!window.setCodexDesktopConnected) {
setError("The ChatGPT integration is unavailable.");
return;
}
if (enabled && !installed) {
if (!window.installCodexDesktop || !window.getCodexDesktopStatus) {
setError("Ollama could not install ChatGPT.");
return;
}
const installResult = await window.installCodexDesktop();
if (!mounted.current) return;
if (installResult === "opened") finalPhase = "waiting-for-install";
else if (installResult !== "cancelled")
setError("Ollama could not install ChatGPT.");
return;
}
if (fromIntro || (enabled && !status?.used && !used.current)) {
if (!window.getCodexDesktopStatus) {
setError("Ollama could not read the ChatGPT connection status.");
return;
}
const liveStatus = await window.getCodexDesktopStatus();
if (!mounted.current) return;
setStatus(liveStatus);
if (liveStatus.running && !restartConfirmed) {
restartConfirmed = window.confirm(
"Restart ChatGPT to add Ollama models? Any running task will stop.",
);
if (!restartConfirmed) return;
}
if (!fromIntro && !liveStatus.used && !used.current) {
introRestartConfirmed.current = restartConfirmed;
setShowIntro(true);
return;
}
}
let result: CodexDesktopActionResult =
await window.setCodexDesktopConnected(enabled, false);
await window.setCodexDesktopConnected(enabled, restartConfirmed);
setStatus(result.status);
if (result.restartConfirmationRequired) {
if (!mounted.current) return;
// Keep focus-driven status refreshes from discarding this operation
// while the native confirmation dialog temporarily owns focus.
if (
@@ -290,11 +427,11 @@ export function CodexDesktopRow({
) {
return;
}
setPhase(nextPhase);
result = await window.setCodexDesktopConnected(enabled, true);
setStatus(result.status);
}
if (result.restartConfirmationRequired) return;
if (result.error) {
setError(result.error);
return;
@@ -307,20 +444,25 @@ export function CodexDesktopRow({
);
return;
}
setNotice(
enabled
? "Ollama models added alongside Codex models"
: "Ollama models removed · Codex models remain available",
);
if (fromIntro) {
setPhase("saving");
if (!(await saveAcknowledgment())) return;
}
if (enabled) {
setNotice("Ollama models added alongside Codex models");
} else {
setNotice("Ollama models removed · Codex models remain available");
}
} catch {
setError(
enabled
? "Ollama could not add its models to ChatGPT."
: "Ollama could not remove its models from ChatGPT.",
nextPhase === "installing"
? "Ollama could not install ChatGPT."
: enabled
? "Ollama could not add its models to ChatGPT."
: "Ollama could not remove its models from ChatGPT.",
);
} finally {
operationInFlight.current = false;
if (mounted.current) setPhase("idle");
finishOperation(finalPhase);
}
};
@@ -333,7 +475,7 @@ export function CodexDesktopRow({
ChatGPT (Desktop)
</p>
<p
role={error ? "alert" : notice ? "status" : undefined}
role={actionError ? "alert" : notice ? "status" : undefined}
className="truncate text-xs leading-5 text-neutral-500 dark:text-neutral-400"
>
{description}
@@ -341,15 +483,24 @@ export function CodexDesktopRow({
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2.5">
{acknowledgmentFailed && (
<button
type="button"
aria-label="Retry saving progress"
disabled={pending}
onClick={() => void retryAcknowledgment()}
className="text-xs font-medium text-neutral-700 hover:underline disabled:cursor-wait disabled:opacity-50 dark:text-neutral-300"
>
Retry
</button>
)}
{statusLabel && (
<span
role="status"
aria-live="polite"
className="inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-neutral-500 dark:text-neutral-400"
>
{isConnecting && (
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />
)}
{pending && <ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />}
{statusLabel}
</span>
)}
@@ -359,11 +510,13 @@ export function CodexDesktopRow({
aria-checked={displayedConnected}
aria-busy={pending || undefined}
aria-label={
connected
? "Remove Ollama models from ChatGPT"
: isConnecting
? "Connecting ChatGPT"
: "Add Ollama models to ChatGPT"
showIntro
? "Finish connecting ChatGPT"
: connected
? "Remove Ollama models from ChatGPT"
: pending
? "Connecting ChatGPT"
: "Add Ollama models to ChatGPT"
}
title={
connected
@@ -372,7 +525,7 @@ export function CodexDesktopRow({
? "Add Ollama models"
: "Install ChatGPT and add Ollama models"
}
disabled={pending}
disabled={pending || showIntro}
onClick={() => void toggleConnection()}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-50 ${displayedConnected ? "bg-neutral-950 dark:bg-white" : "bg-neutral-300 dark:bg-neutral-700"}`}
>
@@ -382,6 +535,9 @@ export function CodexDesktopRow({
/>
</button>
</div>
{showIntro && (
<CodexConnectedIntro onDone={() => void toggleConnection(true)} />
)}
</div>
);
}
+20 -1
View File
@@ -1,6 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient } from "@tanstack/react-query";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ClaudeConnectedIntro,
FIRST_MODEL_COMMAND,
@@ -25,6 +26,24 @@ import {
} from "@/lib/onboarding";
import type { IntegrationStatuses } from "@/api";
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient();
});
afterEach(() => queryClient.clear());
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return Object.assign({}, actual, {
useQueryClient: () => queryClient,
useMutation: (options: Parameters<typeof actual.useMutation>[0]) =>
actual.useMutation(options, queryClient),
useMutationState: (
options: Parameters<typeof actual.useMutationState>[0],
) => actual.useMutationState(options, queryClient),
});
});
describe("Onboarding", () => {
it("explains what Ollama is before asking the user to choose a path", () => {
const html = renderToStaticMarkup(<IntroScreen onContinue={vi.fn()} />);
+2
View File
@@ -62,6 +62,7 @@ interface ClaudeDesktopActionResult {
}
interface CodexDesktopStatus {
used?: boolean;
supported: boolean;
installed: boolean;
connected: boolean;
@@ -138,6 +139,7 @@ declare global {
) => Promise<ClaudeDesktopActionResult>;
prepareClaudeDesktopConnection?: () => Promise<ClaudeDesktopActionResult>;
openClaudeDesktop?: () => Promise<string>;
markCodexDesktopIntegrationUsed?: () => Promise<string>;
getCodexDesktopStatus?: () => Promise<CodexDesktopStatus>;
getCodexDesktopRequestCount?: () => Promise<number>;
setCodexDesktopConnected?: (
+5
View File
@@ -1561,6 +1561,11 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error {
if err := s.Store.SetSettings(settings); err != nil {
return fmt.Errorf("failed to save settings: %w", err)
}
saved, err := s.Store.Settings()
if err != nil {
return fmt.Errorf("failed to load saved settings: %w", err)
}
settings.CodexDesktopUsed = saved.CodexDesktopUsed
// Handle auto-update toggle changes
if old.AutoUpdateEnabled != settings.AutoUpdateEnabled {
+152
View File
@@ -17,6 +17,7 @@ import (
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/app/store"
"github.com/ollama/ollama/app/ui/responses"
"github.com/ollama/ollama/app/updater"
"github.com/ollama/ollama/cmd/launch"
)
@@ -1061,3 +1062,154 @@ func TestSettingsToggleAutoUpdateOn_NoPendingUpdate_DoesNotNotify(t *testing.T)
t.Fatal("UpdateAvailableFunc should not be called when there is no pending update")
}
}
func TestSettingsPreservesCodexDesktopUsedWhenOmitted(t *testing.T) {
testStore := &store.Store{
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
}
defer testStore.Close()
settings, err := testStore.Settings()
if err != nil {
t.Fatal(err)
}
if err := testStore.MarkCodexDesktopUsed(); err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
var fields map[string]any
if err := json.Unmarshal(payload, &fields); err != nil {
t.Fatal(err)
}
delete(fields, "CodexDesktopUsed")
payload, err = json.Marshal(fields)
if err != nil {
t.Fatal(err)
}
server := &Server{Store: testStore, Restart: func() {}}
req := httptest.NewRequest("POST", "/api/v1/settings", bytes.NewReader(payload))
rr := httptest.NewRecorder()
if err := server.settings(rr, req); err != nil {
t.Fatalf("settings() error = %v", err)
}
saved, err := testStore.Settings()
if err != nil {
t.Fatal(err)
}
if !saved.CodexDesktopUsed {
t.Fatal("expected CodexDesktopUsed to be preserved")
}
}
func TestSettingsPreservesCodexDesktopUsedWithStaleValue(t *testing.T) {
testStore := &store.Store{
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
}
defer testStore.Close()
settings, err := testStore.Settings()
if err != nil {
t.Fatal(err)
}
if err := testStore.MarkCodexDesktopUsed(); err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
var fields map[string]any
if err := json.Unmarshal(payload, &fields); err != nil {
t.Fatal(err)
}
fields["CodexDesktopUsed"] = false
payload, err = json.Marshal(fields)
if err != nil {
t.Fatal(err)
}
server := &Server{Store: testStore, Restart: func() {}}
req := httptest.NewRequest("POST", "/api/v1/settings", bytes.NewReader(payload))
rr := httptest.NewRecorder()
if err := server.settings(rr, req); err != nil {
t.Fatalf("settings() error = %v", err)
}
saved, err := testStore.Settings()
if err != nil {
t.Fatal(err)
}
if !saved.CodexDesktopUsed {
t.Fatal("expected CodexDesktopUsed to be preserved")
}
}
type settingsBodyReadHook struct {
io.Reader
onRead func()
}
func (r *settingsBodyReadHook) Read(p []byte) (int, error) {
if r.onRead != nil {
onRead := r.onRead
r.onRead = nil
onRead()
}
return r.Reader.Read(p)
}
func TestSettingsPreservesConcurrentCodexDesktopAcknowledgment(t *testing.T) {
testStore := &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}
defer testStore.Close()
settings, err := testStore.Settings()
if err != nil {
t.Fatal(err)
}
settings.Browser = !settings.Browser
payload, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
body := &settingsBodyReadHook{
Reader: bytes.NewReader(payload),
onRead: func() {
// The handler has read the old settings but has not saved the request yet.
if err := testStore.MarkCodexDesktopUsed(); err != nil {
t.Fatal(err)
}
},
}
server := &Server{Store: testStore, Restart: func() {}}
req := httptest.NewRequest("POST", "/api/v1/settings", body)
rr := httptest.NewRecorder()
if err := server.settings(rr, req); err != nil {
t.Fatal(err)
}
saved, err := testStore.Settings()
if err != nil {
t.Fatal(err)
}
if !saved.CodexDesktopUsed {
t.Error("overlapping settings save erased the acknowledgment")
}
if saved.Browser != settings.Browser {
t.Error("overlapping acknowledgment lost the requested setting")
}
var response responses.SettingsResponse
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if !response.Settings.CodexDesktopUsed {
t.Error("settings response returned a stale acknowledgment")
}
}