mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 05:28:00 -05:00
app: add a first-use ChatGPT connection intro (#18321)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ChatGPT’s 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 wasn’t 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 couldn’t 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 ChatGPT’s 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
Vendored
+2
@@ -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?: (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user