From 742173a13250bcba880b700aaead34e1b0810dff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 18:15:07 -0700 Subject: [PATCH] feat(settings): resolve t3.json inside the project settings resolver (#12954) Co-authored-by: Claude Fable 5 --- .../SettingsServerControlsRouteScreen.tsx | 25 ++++-- .../settings/settings-scoped-server.test.ts | 33 ++++++++ .../settings/settings-scoped-server.ts | 15 +++- .../threads/new-task-flow-provider.tsx | 49 +++++------- .../files/projectFilesQueryState.ts | 7 +- .../settings/ProjectDefaultsSettings.tsx | 45 ++++------- .../settings/SettingInheritance.test.ts | 37 +++++++++ .../settings/SettingInheritance.tsx | 47 +++++++++--- .../settings/SettingsScopeContext.tsx | 62 ++++++++++++++- .../settings/scopedSettings.test.ts | 47 ++++++++++++ .../src/components/settings/scopedSettings.ts | 28 ++++++- .../components/settings/settingsLayout.tsx | 12 +-- apps/web/src/hooks/useHandleNewThread.test.ts | 25 +++--- apps/web/src/hooks/useHandleNewThread.ts | 29 +++---- apps/web/src/lib/t3ProjectFileDefaults.ts | 18 ++--- docs/user/project-settings.md | 4 +- packages/contracts/src/baseSchemas.ts | 25 ++++++ packages/contracts/src/settings.test.ts | 21 +++++ packages/contracts/src/settings.ts | 34 ++++++++- packages/contracts/src/t3ProjectFile.ts | 35 +++++++++ packages/shared/package.json | 4 - packages/shared/src/projectSettings.test.ts | 76 +++++++++++++++++++ packages/shared/src/projectSettings.ts | 72 +++++++++++++++++- packages/shared/src/threadEnvMode.test.ts | 65 ---------------- packages/shared/src/threadEnvMode.ts | 36 --------- 25 files changed, 615 insertions(+), 236 deletions(-) delete mode 100644 packages/shared/src/threadEnvMode.test.ts delete mode 100644 packages/shared/src/threadEnvMode.ts diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 090467a8cd9095d294aa3eec93564f40e67a4be4..94312207714ad2a4b17d69282ce2028876bc123b 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -50,10 +50,16 @@ const PAGE_PROJECT_KEYS: Record = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or the current checkout.", + }, { mode: "local", label: "Current checkout", @@ -126,6 +132,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const value = reference.settings[key]; return displayTargets.every((entry) => entry.settings[key] === value) ? value : null; }; + // `uniform` folds a real null into "mixed"; nullable keys need the distinction. + const isMixed = (key: keyof ServerSettings) => + reference === null || + displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "environment settings update", reportFailure: true, @@ -219,17 +229,22 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null } > - {WORKSPACE_CHOICES.map((choice, index) => ( + {WORKSPACE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( 0} disabled={disabledFor("defaultThreadEnvMode")} onPress={() => write({ defaultThreadEnvMode: choice.mode })} diff --git a/apps/mobile/src/features/settings/settings-scoped-server.test.ts b/apps/mobile/src/features/settings/settings-scoped-server.test.ts index dd589cd2038c6bcb863cdd2a00ce333d745a283d..185fb07e602fd770b8f633db9ee2a13154419135 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -69,6 +69,39 @@ describe("mobile project settings scope", () => { expect(secondSettings.responseStreamingMode).toBe("token"); }); + it("removes a project override when a picker sends null for a key that cannot store it", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [firstProject]: { defaultThreadEnvMode: "worktree", defaultAutoPull: true }, + }, + }; + const targets = resolveMobileSettingsTargets( + [environment(firstId, settings)], + [{ environmentId: firstId, id: firstProject }], + ); + expect(planMobileScopedSettingsPatch(targets, true, { defaultThreadEnvMode: null })).toEqual([ + { + environmentId: firstId, + patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } }, + }, + ]); + expect(planMobileScopedSettingsPatch(targets, true, { defaultModelSelection: null })).toEqual([ + { + environmentId: firstId, + patch: { + projectSettingsOverrides: { + [firstProject]: { + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + defaultModelSelection: null, + }, + }, + }, + }, + ]); + }); + it("resets only the selected page's override and rejects environment-wide writes", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 53cc44a7ee1c9a58d04abccfce47469d6aa954b7..01020a3eb221c215fd22e79d8074e70099976324 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -1,4 +1,5 @@ import { + isNullableProjectSettingsOverride, PROJECT_SCOPED_SERVER_SETTING_KEYS, type EnvironmentId, type ProjectId, @@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch( continue; const current = target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; - const next = { ...current, ...patch }; + const next: Record = { ...current }; + for (const [key, value] of Object.entries(patch)) { + // A picker's "Inherit" sends null; for keys whose override cannot + // store null that means remove the override. + if ( + value === null && + !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey) + ) { + delete next[key]; + } else { + next[key] = value; + } + } const overrides = writes.get(target.environment.environmentId) ?? {}; overrides[target.projectId] = next; writes.set(target.environment.environmentId, overrides); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 4a912f171a5560dd8d6b5b187585dc12df261b0a..f2801255b3bcd4210854b80046dda405f3386722 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -20,10 +20,6 @@ import { } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; -import { - isDefaultThreadEnvModeSettled, - resolveDefaultThreadEnvMode, -} from "@t3tools/shared/threadEnvMode"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { : null, ); const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; - const t3ProjectFileDefaultMode = useMemo(() => { - if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; - return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; - }, [t3ProjectFileData]); - // Environment settings with the project's overrides applied; the - // aggregate's own legacy fields still count until the server folds them. + const t3ProjectFile = useMemo( + () => + t3ProjectFileData === null || t3ProjectFileData.truncated + ? null + : parseT3ProjectFile(t3ProjectFileData.contents), + [t3ProjectFileData], + ); + // Environment settings with the project's overrides and its t3.json + // applied; the aggregate's own legacy fields still count until the server + // folds them. const projectSettings = useMemo( () => resolveProjectSettings( selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, selectedProject?.id ?? null, selectedProject, + t3ProjectFile, ), - [selectedEnvironmentServerConfig?.settings, selectedProject], + [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], ); - const projectThreadEnvMode = - projectSettings.sources.defaultThreadEnvMode === "project" - ? projectSettings.settings.defaultThreadEnvMode - : undefined; - const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: projectThreadEnvMode, - projectFile: t3ProjectFileDefaultMode, - globalDefault: projectSettings.settings.defaultThreadEnvMode, - }); - // While unsettled the resolved default is provisional. Nothing may write - // it into the draft during that window (the auto-branch effect does), or - // the frozen interim value beats the t3.json default once it loads. - const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ - explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: projectThreadEnvMode, - projectFilePending: t3ProjectFileQuery.isPending, - }); + const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode; + // While the file read is pending and nothing above it decided, the + // resolved default is provisional. Nothing may write it into the draft + // during that window (the auto-branch effect does), or the frozen interim + // value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = + selectedProjectDraft.workspaceSelection?.mode !== undefined || + projectSettings.sources.defaultThreadEnvMode !== "environment" || + !t3ProjectFileQuery.isPending; const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index b9a880301831743cee2e16bdf48e08b56fb2aa01..a6a556231e1895876f3e8a341775618c8555a783 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -20,7 +20,12 @@ const EMPTY_PROJECT_FILE_PATH = ""; const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( AsyncResult.initial(false), ).pipe(Atom.withLabel("project-file-query:empty")); -function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { +/** A pending in-app write to the file, overlaying the query until confirmed. */ +export function optimisticFileAtom( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +) { return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); } diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index a08b918be4c5f9e86156ca709ef976956df3e7a1..d49eaa5cbf85d2447ade2e0a8b991ffda178e270 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -1,13 +1,12 @@ import { DEFAULT_SERVER_SETTINGS, - EnvironmentId, type ModelSelection, type ProviderInstanceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useNavigate } from "@tanstack/react-router"; -import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -75,25 +74,16 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); const modelSource = useScopedSettingSource(["defaultModelSelection"]); - const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unavailable = connectedEnvironments.length === 0; - - // A checkout's t3.json wins over the environment default when the project - // has no override of its own; show which one "inherit" resolves to. - const checkout = scope.kind === "checkout" ? scope.checkout : null; - // The query is disabled without a checkout, so any id satisfies the hook. - const t3File = useT3ProjectFileState( - checkout?.environmentId ?? EnvironmentId.make("none"), - category === "general" && checkout ? checkout.workspaceRoot : null, - ); - const repositoryEnvMode = t3File.file?.defaultThreadEnvMode ?? null; - const inheritedEnvModeLabel = - workspaceSource === "project" - ? null - : repositoryEnvMode - ? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)` - : null; + // File-backed keys show their effective value; the target already carries + // the checkout's t3.json, and a null file here only fills the built-in. + // The reset arrow beside the title clears the tier (SettingsRow handles a + // project override, the environment value is cleared here), so the picker + // has no "inherit" item. + const effective = target + ? resolveProjectSettings(target.settings, null, null, null).settings + : null; function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); @@ -292,27 +282,20 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting title="Workspace" description={ isProjectScope - ? "Where new threads in this project start. A t3.json preference applies when the project has no override." - : "Where new threads start, unless overridden by the project or t3.json." - } - status={ - inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined + ? "Where new threads in this project start." + : "Where new threads start. Projects and their t3.json can override it." } resetAction={ - settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? ( + !isProjectScope && settings.defaultThreadEnvMode !== null ? ( - updateSettings({ - defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, - }) - } + onClick={() => updateSettings({ defaultThreadEnvMode: null })} /> ) : null } control={