diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.test.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.test.tsx new file mode 100644 index 000000000000..1ab648caa587 --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.test.tsx @@ -0,0 +1,247 @@ +import type { ReactElement } from "react"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ModelSelection, + type ServerSettings, +} from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; +import { visitElements } from "../../test/reactElementTree"; + +const state = vi.hoisted(() => ({ + environments: [] as Array<{ + environmentId: EnvironmentId; + label: string; + connection: { phase: "connected" | "disconnected" }; + serverConfig: { settings: ServerSettings; providers: [] } | null; + }>, + projects: [] as Array<{ + environmentId: EnvironmentId; + id: ProjectId; + title: string; + defaultModelSelection: ModelSelection | null; + }>, + confirm: vi.fn<() => Promise>(), + updateProject: vi.fn(), + updateSettings: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("../../hooks/useSettings", () => ({ + useClientSettings: () => DEFAULT_CLIENT_SETTINGS, + useUpdateClientSettings: () => vi.fn(), +})); +vi.mock("../../state/environments", () => ({ + useEnvironments: () => ({ environments: state.environments }), + usePrimaryEnvironmentId: () => null, +})); +vi.mock("../../state/entities", () => ({ useProjects: () => state.projects })); +vi.mock("../../state/projects", () => ({ projectEnvironment: { update: "update-project" } })); +vi.mock("../../state/server", () => ({ + EMPTY_SERVER_PROVIDERS: [], + serverEnvironment: { updateSettings: "update-settings" }, +})); +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (command: string) => + command === "update-project" ? state.updateProject : state.updateSettings, +})); +vi.mock("../../localApi", () => ({ + readLocalApi: () => ({ dialogs: { confirm: state.confirm } }), +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } })); +vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: () => null })); +vi.mock("../chat/TraitsPicker", () => ({ TraitsPicker: () => null })); +vi.mock("./ProjectSettingsPanel", () => ({ PROJECT_GROUPING_MODE_LABELS: {} })); +vi.mock("./ProjectDefaultActionsSettings", () => ({ ProjectDefaultActionsSettings: () => null })); + +import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; + +const laptop = EnvironmentId.make("laptop"); +const desktop = EnvironmentId.make("desktop"); +const offline = EnvironmentId.make("offline"); +const unavailable = EnvironmentId.make("unavailable"); +const override: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "project-model", +}; + +/** Builds a physical project fixture, allowing the same project ID on different machines. */ +function project(environmentId: EnvironmentId, id: string, inherits = false) { + return { + environmentId, + id: ProjectId.make(id), + title: id, + defaultModelSelection: inherits ? null : override, + }; +} + +/** Renders the scoped reset control while retaining hook state across simulated rerenders. */ +function resetButton(environmentId: EnvironmentId | null = null) { + hooks.beginRender(); + const panel = ProjectDefaultsSettings({ environmentId }); + const row = visitElements(panel, (element) => element.props.title === "Project model overrides"); + return row!.props.control as ReactElement<{ disabled: boolean; onClick: () => Promise }>; +} + +beforeEach(() => { + hooks.reset(); + state.environments = [laptop, desktop, offline, unavailable].map((environmentId) => ({ + environmentId, + label: environmentId, + connection: { phase: environmentId === offline ? "disconnected" : "connected" }, + serverConfig: + environmentId === unavailable + ? null + : { + settings: { + ...DEFAULT_SERVER_SETTINGS, + defaultModelSelection: { ...override, model: `${environmentId}-default` }, + }, + providers: [], + }, + })); + state.projects = [ + project(laptop, "shared-id"), + project(laptop, "already-inherits", true), + project(desktop, "shared-id"), + project(offline, "offline-project"), + project(unavailable, "unavailable-project"), + ]; + state.confirm.mockReset().mockResolvedValue(true); + state.updateProject.mockReset().mockResolvedValue({ _tag: "Success" }); + state.updateSettings.mockReset().mockResolvedValue({ _tag: "Success" }); + state.toast.mockReset(); +}); + +describe("reset project model overrides", () => { + it("only resets overrides on the selected machine, even when project IDs are shared", async () => { + await resetButton(laptop).props.onClick(); + + expect(state.updateProject.mock.calls).toEqual([ + [ + { + environmentId: laptop, + input: { projectId: ProjectId.make("shared-id"), defaultModelSelection: null }, + }, + ], + ]); + expect(state.updateSettings).not.toHaveBeenCalled(); + expect(state.confirm).toHaveBeenCalledWith( + expect.stringContaining("1 project model override on laptop?"), + ); + }); + + it("resets all connected machines to inheritance and reports skipped machines", async () => { + await resetButton().props.onClick(); + + expect(state.updateProject.mock.calls).toEqual([ + [ + { + environmentId: laptop, + input: { projectId: ProjectId.make("shared-id"), defaultModelSelection: null }, + }, + ], + [ + { + environmentId: desktop, + input: { projectId: ProjectId.make("shared-id"), defaultModelSelection: null }, + }, + ], + ]); + expect(state.updateSettings).not.toHaveBeenCalled(); + expect(state.confirm).toHaveBeenCalledWith(expect.stringContaining("inherit later changes")); + expect(state.confirm).toHaveBeenCalledWith( + expect.stringContaining("skipped: offline, unavailable"), + ); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "success", + description: expect.stringContaining( + "2 project model overrides reset. Existing threads are unchanged.", + ), + }), + ); + }); + + it("requires confirmation and releases the pending guard after cancellation", async () => { + let finishConfirmation!: (confirmed: boolean) => void; + state.confirm.mockImplementationOnce( + () => + new Promise((resolve) => { + finishConfirmation = resolve; + }), + ); + const button = resetButton(); + const pending = button.props.onClick(); + expect(resetButton().props.disabled).toBe(true); + await button.props.onClick(); + expect(state.confirm).toHaveBeenCalledTimes(1); + expect(state.updateProject).not.toHaveBeenCalled(); + + finishConfirmation(false); + await pending; + expect(resetButton().props.disabled).toBe(false); + expect(state.updateProject).not.toHaveBeenCalled(); + expect(state.toast).not.toHaveBeenCalled(); + }); + + it("continues after a failure and retries only overrides that remain", async () => { + state.projects.push(project(desktop, "another-checkout")); + state.updateProject.mockImplementation(async ({ environmentId, input }) => { + if (environmentId === laptop) return { _tag: "Failure" }; + state.projects = state.projects.map((project) => + project.environmentId === environmentId && project.id === input.projectId + ? { ...project, defaultModelSelection: input.defaultModelSelection } + : project, + ); + return { _tag: "Success" }; + }); + await resetButton().props.onClick(); + + expect(state.updateProject).toHaveBeenCalledTimes(3); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + description: expect.stringContaining( + "2 project model overrides reset. Existing threads are unchanged. Could not reset shared-id (laptop).", + ), + }), + ); + state.updateProject.mockClear().mockResolvedValue({ _tag: "Success" }); + await resetButton().props.onClick(); + expect(state.updateProject).toHaveBeenCalledTimes(1); + expect(state.updateProject).toHaveBeenCalledWith({ + environmentId: laptop, + input: { projectId: ProjectId.make("shared-id"), defaultModelSelection: null }, + }); + }); + + it("does not offer a reset when no connected projects have overrides", async () => { + expect(resetButton(offline).props.disabled).toBe(true); + await resetButton(offline).props.onClick(); + state.projects = state.projects.map((project) => ({ ...project, defaultModelSelection: null })); + expect(resetButton().props.disabled).toBe(true); + await resetButton().props.onClick(); + expect(state.confirm).not.toHaveBeenCalled(); + expect(state.updateProject).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 938000e01002..44350c30b66a 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -6,12 +6,14 @@ import { type ProviderInstanceId, type ServerSettingsPatch, } from "@t3tools/contracts"; +import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { createModelSelection } from "@t3tools/shared/model"; import { useNavigate } from "@tanstack/react-router"; import { useRef, useState } from "react"; import { Trash2Icon } from "lucide-react"; import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { readLocalApi } from "../../localApi"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -19,6 +21,8 @@ import { resolveDefaultProviderModelSelection, sortProviderInstanceEntries, } from "../../providerInstances"; +import { useProjects } from "../../state/entities"; +import { projectEnvironment } from "../../state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -48,6 +52,8 @@ export function ProjectDefaultsSettings({ environmentId: EnvironmentId | null; }) { const { environments } = useEnvironments(); + const projects = useProjects(); + const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const primaryEnvironmentId = usePrimaryEnvironmentId(); const clientSettings = useClientSettings(); const updateClientSettings = useUpdateClientSettings(); @@ -65,6 +71,15 @@ export function ProjectDefaultsSettings({ (environment) => environment.connection.phase === "connected" && environment.serverConfig !== null, ); + const targetEnvironmentIds = new Set(targets.map((target) => target.environmentId)); + const modelOverrides = projects.filter( + (project) => + targetEnvironmentIds.has(project.environmentId) && project.defaultModelSelection !== null, + ); + const skipped = scoped.filter( + (environment) => !targetEnvironmentIds.has(environment.environmentId), + ); + const resettingModels = saving.has("projectModelOverrides"); const representative = targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0]; const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; @@ -96,11 +111,13 @@ export function ProjectDefaultsSettings({ target.serverConfig?.settings.enableAgentBrowserAccess !== serverSettings.enableAgentBrowserAccess, ); - const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key); + const disabled = (key: keyof ServerSettingsPatch) => + targets.length === 0 || saving.has(key) || (key === "defaultModelSelection" && resettingModels); const mixedAutoPull = targets.some( (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull, ); + /** Explains why a model cannot be used by every connected machine in the current scope. */ function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); for (const target of targets) { @@ -126,6 +143,7 @@ export function ProjectDefaultsSettings({ return null; } + /** Saves machine defaults without changing project overrides, reporting partial failures. */ async function save(patch: ServerSettingsPatch) { const keys = Object.keys(patch); if (targets.length === 0 || keys.some((key) => savingRef.current.has(key))) return; @@ -157,6 +175,75 @@ export function ProjectDefaultsSettings({ } } + /** + * Confirms and clears scoped project overrides so they inherit future machine defaults. + * Failed updates leave their overrides available for retry; existing threads are untouched. + */ + async function resetProjectModels() { + const api = readLocalApi(); + if ( + !api || + modelOverrides.length === 0 || + savingRef.current.has("projectModelOverrides") || + savingRef.current.has("defaultModelSelection") + ) + return; + + savingRef.current.add("projectModelOverrides"); + setSaving(new Set(savingRef.current)); + try { + const skippedDescription = + skipped.length > 0 + ? `Offline or unavailable machines are skipped: ${skipped.map((target) => target.label).join(", ")}.` + : ""; + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Reset ${modelOverrides.length} project model override${modelOverrides.length === 1 ? "" : "s"} on ${targets.map((target) => target.label).join(", ")}?`, + "These projects will use their machine's default model for new threads and inherit later changes to that default.", + "Existing threads keep their current models.", + skippedDescription, + ] + .filter(Boolean) + .join("\n\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + + const failed: string[] = []; + for (const project of modelOverrides) { + const result = await updateProject({ + environmentId: project.environmentId, + input: { projectId: project.id, defaultModelSelection: null }, + }); + if (result._tag === "Failure") { + const machine = targets.find((target) => target.environmentId === project.environmentId); + failed.push(`${project.title} (${machine?.label ?? "unknown machine"})`); + } + } + const resetCount = modelOverrides.length - failed.length; + toastManager.add({ + type: failed.length > 0 ? "error" : "success", + title: + failed.length > 0 + ? "Some project model overrides could not be reset" + : "Project models now use machine defaults", + description: [ + `${resetCount} project model override${resetCount === 1 ? "" : "s"} reset. Existing threads are unchanged.`, + failed.length > 0 + ? `Could not reset ${failed.slice(0, 3).join(", ")}${failed.length > 3 ? ` and ${failed.length - 3} more` : ""}. Try again to retry remaining overrides.` + : "", + skippedDescription, + ] + .filter(Boolean) + .join(" "), + }); + } finally { + savingRef.current.delete("projectModelOverrides"); + setSaving(new Set(savingRef.current)); + } + } + const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value }); return ( @@ -272,6 +359,29 @@ export function ProjectDefaultsSettings({ ) } /> + 0 + ? `${modelOverrides.length} override${modelOverrides.length === 1 ? "" : "s"}` + : skipped.length > 0 + ? "Connected projects inherit" + : "All projects inherit" + } + control={ + + } + />