Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ import {
} from "../../native/StackHeader";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { ChatGptSharingStatus } from "./ChatGptSharingStatus";
import { environmentCatalog } from "../../connection/catalog";
import { useEnvironmentQuery } from "../../state/query";
import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server";
import { mobilePreferencesAtom } from "../../state/preferences";
import { useAtomCommand } from "../../state/use-atom-command";
Expand Down Expand Up @@ -137,6 +139,8 @@ const EMPTY_MODEL_FAVORITES: ReadonlyArray<{
readonly model: string;
}> = [];
const FAVORITES_PROVIDER_FILTER = "@favorites";
// Servers that predate `migrateModelPreferences` drop it and keep reporting null favorites.
const attemptedMigrationGenerations = new Map<EnvironmentId, number>();
/** Provider catalog header with its harness logo and disclosure state. */
function ProviderHeader(props: {
readonly driver: string | undefined;
Expand Down Expand Up @@ -352,6 +356,10 @@ function ThreadSettingsSessionProvider(
const configs = useAtomValue(environmentServerConfigsAtom);
const config = props.environmentId ? configs.get(props.environmentId) : undefined;
const settings = config?.settings;
const connection = useEnvironmentQuery(
props.environmentId ? environmentCatalog.stateAtom(props.environmentId) : null,
).data;
const connectionGeneration = connection?.phase === "connected" ? connection.generation : null;
const [manageHidden, setManageHidden] = useState(false);
const pendingHidden = useRef(new Set<string>());
const pendingFavorites = useRef(
Expand Down Expand Up @@ -390,10 +398,26 @@ function ThreadSettingsSessionProvider(
}).favorites;
const saveSettings = useAtomCommand(serverEnvironment.updateSettings, "model favorites update");
useEffect(() => {
if (!props.environmentId || !settings || !AsyncResult.isSuccess(preferences)) return;
if (
!props.environmentId ||
!settings ||
!AsyncResult.isSuccess(preferences) ||
connectionGeneration === null ||
attemptedMigrationGenerations.get(props.environmentId) === connectionGeneration
)
return;
const patch = modelPreferencesMigrationPatch(settings, { favorites: legacyFavorites });
if (patch) void saveSettings({ environmentId: props.environmentId, input: { patch } });
}, [legacyFavorites, preferences, props.environmentId, saveSettings, settings]);
if (!patch) return;
attemptedMigrationGenerations.set(props.environmentId, connectionGeneration);
void saveSettings({ environmentId: props.environmentId, input: { patch } });
}, [
connectionGeneration,
legacyFavorites,
preferences,
props.environmentId,
saveSettings,
settings,
]);
const favoriteKeys = useMemo(
() =>
new Set(
Expand Down
130 changes: 130 additions & 0 deletions apps/web/src/hooks/useSettings.migration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { RegistryContext } from "@effect/atom-react";
import {
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
ProviderInstanceId,
type ServerSettings,
} from "@t3tools/contracts";
import {
AVAILABLE_CONNECTION_STATE,
type SupervisorConnectionState,
} from "@t3tools/client-runtime/connection";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";

const settingsFromServerWithoutMigration = DEFAULT_SERVER_SETTINGS;

const state = vi.hoisted(() => ({
settings: undefined as unknown as Atom.Writable<ServerSettings | null>,
connection: undefined as unknown as Atom.Writable<
AsyncResult.AsyncResult<SupervisorConnectionState>
>,
persist: vi.fn(),
}));

vi.mock("~/connection/catalog", () => ({
environmentCatalog: { stateAtom: () => state.connection },
}));

vi.mock("~/state/server", () => ({
serverEnvironment: {
settingsValueAtom: () => state.settings,
updateSettings: Symbol("updateSettings"),
},
primaryServerSettingsAtom: undefined,
}));
vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.persist }));
vi.mock("~/localApi", () => ({
ensureLocalApi: () => ({
persistence: {
getClientSettings: async () => ({
favorites: [{ provider: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }],
}),
},
}),
}));

import { useEnvironmentModelPreferences } from "./useSettings";

function ModelPicker(props: { readonly environmentId: EnvironmentId }) {
useEnvironmentModelPreferences(props.environmentId);
return null;
}

let registry: AtomRegistry.AtomRegistry;
let renderer: ReactTestRenderer | undefined;

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
registry = AtomRegistry.make();
state.settings = Atom.make<ServerSettings | null>(settingsFromServerWithoutMigration);
state.connection = Atom.make<AsyncResult.AsyncResult<SupervisorConnectionState>>(
AsyncResult.success(connected(1)),
);
state.persist
.mockReset()
.mockResolvedValue(AsyncResult.success(settingsFromServerWithoutMigration));
});

function connected(generation: number): SupervisorConnectionState {
return { ...AVAILABLE_CONNECTION_STATE, desired: true, phase: "connected", generation };
}

async function renderModelPicker(environmentId: EnvironmentId) {
await act(async () => {
renderer = create(
<RegistryContext.Provider value={registry}>
<ModelPicker environmentId={environmentId} />
</RegistryContext.Provider>,
);
});
}

async function pushSettings(count: number) {
for (let push = 0; push < count; push += 1) {
await act(async () => registry.set(state.settings, { ...settingsFromServerWithoutMigration }));
}
}

afterEach(async () => {
await act(() => renderer?.unmount());
registry.dispose();
vi.unstubAllGlobals();
});

it("migrates once when the server drops the migration and keeps pushing settings", async () => {
const environmentId = EnvironmentId.make("remote");
await renderModelPicker(environmentId);
await pushSettings(3);
expect(state.persist).toHaveBeenCalledTimes(1);
expect(state.persist).toHaveBeenCalledWith({
environmentId,
input: {
patch: {
migrateModelPreferences: {
favorites: [{ provider: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }],
},
},
},
});
});

it("migrates again after the environment reconnects", async () => {
await renderModelPicker(EnvironmentId.make("upgraded"));
await pushSettings(2);
await act(async () => registry.set(state.connection, AsyncResult.success(connected(2))));
await pushSettings(2);
expect(state.persist).toHaveBeenCalledTimes(2);
});

it("waits for the environment to connect before migrating", async () => {
registry.set(state.connection, AsyncResult.success(AVAILABLE_CONNECTION_STATE));
await renderModelPicker(EnvironmentId.make("connecting"));
await pushSettings(2);
expect(state.persist).not.toHaveBeenCalled();
await act(async () => registry.set(state.connection, AsyncResult.success(connected(1))));
await pushSettings(2);
expect(state.persist).toHaveBeenCalledTimes(1);
});
1 change: 1 addition & 0 deletions apps/web/src/hooks/useSettings.sync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const state = vi.hoisted(() => ({
}));

vi.mock("~/connection/runtime", () => ({ connectionAtomRuntime: undefined }));
vi.mock("~/connection/catalog", () => ({ environmentCatalog: {} }));
vi.mock("@t3tools/client-runtime/state/session", () => ({
createEnvironmentSessionAtoms: () => ({
sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!,
Expand Down
26 changes: 19 additions & 7 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";
import { toastManager } from "~/components/ui/toast";
import { isHostedStaticApp } from "~/hostedPairing";
import { environmentCatalog } from "~/connection/catalog";
import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
import { useAtomCommand } from "~/state/use-atom-command";
Expand Down Expand Up @@ -330,7 +331,18 @@ export function mergeEnvironmentSettings(
};
}

const pendingModelPreferencesMigrations = new Set<EnvironmentId>();
// Servers that predate `migrateModelPreferences` drop it and keep reporting null favorites.
const attemptedMigrationGenerations = new Map<EnvironmentId, number>();
const connectionGenerationAtom = Atom.family((environmentId: EnvironmentId | null) =>
Atom.make((get) =>
environmentId === null
? null
: Option.match(AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))), {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onNone: () => null,
onSome: (connection) => (connection.phase === "connected" ? connection.generation : null),
}),
),
);

/** Seed saved client preferences once without overwriting another client's edits. */
export function useMigrateModelPreferences(
Expand All @@ -339,22 +351,22 @@ export function useMigrateModelPreferences(
) {
const clientSettings = useClientSettingsValue();
const hydrated = useClientSettingsHydrated();
const connectionGeneration = useAtomValue(connectionGenerationAtom(environmentId));
const migrate = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
useEffect(() => {
if (
!environmentId ||
!settings ||
!hydrated ||
pendingModelPreferencesMigrations.has(environmentId)
connectionGeneration === null ||
attemptedMigrationGenerations.get(environmentId) === connectionGeneration
)
return;
const patch = modelPreferencesMigrationPatch(settings, clientSettings);
if (!patch) return;
pendingModelPreferencesMigrations.add(environmentId);
void migrate({ environmentId, input: { patch } }).finally(() =>
pendingModelPreferencesMigrations.delete(environmentId),
);
}, [clientSettings, environmentId, hydrated, migrate, settings]);
attemptedMigrationGenerations.set(environmentId, connectionGeneration);
void migrate({ environmentId, input: { patch } });
}, [clientSettings, connectionGeneration, environmentId, hydrated, migrate, settings]);
}

function useMergedSettings<T>(
Expand Down
Loading