From 98256de8d7cd4fc666d4fe77fd02ec67449f6634 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:10:56 -0700 Subject: [PATCH 1/9] feat(settings): resolve the thread workspace default through one chain The default workspace for new threads resolved as project override, then t3.json, then the environment setting, while the new submodule setting resolves the environment tier before the file. Two orders for two keys that look alike is a trap. Make defaultThreadEnvMode nullable so the environment can defer, and route it through a shared resolveProjectFileDefault: project setting, environment setting, t3.json, built-in default. A stored value keeps winning over the file, so existing installs behave as before; fresh installs and anyone who picks the new Inherit option let the repository decide. Web and mobile gain Inherit in the Workspace picker and show what it resolves to. Co-Authored-By: Claude Fable 5 --- .../SettingsServerControlsRouteScreen.tsx | 20 ++++++-- .../threads/new-task-flow-provider.tsx | 11 ++--- .../settings/ProjectDefaultsSettings.tsx | 47 +++++++------------ .../settings/SettingInheritance.tsx | 4 +- apps/web/src/hooks/useHandleNewThread.test.ts | 4 +- apps/web/src/hooks/useHandleNewThread.ts | 11 ++--- docs/user/project-settings.md | 4 +- packages/contracts/src/settings.test.ts | 10 ++++ packages/contracts/src/settings.ts | 10 ++-- packages/shared/package.json | 4 ++ packages/shared/src/projectFileDefaults.ts | 34 ++++++++++++++ packages/shared/src/threadEnvMode.test.ts | 40 +++++----------- packages/shared/src/threadEnvMode.ts | 35 +++++--------- 13 files changed, 131 insertions(+), 103 deletions(-) create mode 100644 packages/shared/src/projectFileDefaults.ts diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 090467a8cd90..ab379393b3ff 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -50,10 +50,15 @@ const PAGE_PROJECT_KEYS: Record = [ + { + mode: null, + label: "Inherit", + description: "Follow the repository's t3.json, or the current checkout.", + }, { mode: "local", label: "Current checkout", @@ -126,6 +131,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 +228,20 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null } > {WORKSPACE_CHOICES.map((choice, index) => ( 0} disabled={disabledFor("defaultThreadEnvMode")} onPress={() => write({ defaultThreadEnvMode: choice.mode })} 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 4a912f171a55..da70c28ecab9 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -446,21 +446,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ), [selectedEnvironmentServerConfig?.settings, selectedProject], ); - const projectThreadEnvMode = - projectSettings.sources.defaultThreadEnvMode === "project" - ? projectSettings.settings.defaultThreadEnvMode - : undefined; + // Project override over environment value; null when neither is set. + const threadEnvModeSetting = projectSettings.settings.defaultThreadEnvMode; const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: projectThreadEnvMode, + setting: threadEnvModeSetting, 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, + setting: threadEnvModeSetting, projectFilePending: t3ProjectFileQuery.isPending, }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index a08b918be4c5..ac58396f3251 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -75,12 +75,11 @@ 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. + // A setting on either scope beats the checkout's t3.json, and "inherit" + // (null) is what lets the file decide; show what 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( @@ -89,11 +88,11 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ); const repositoryEnvMode = t3File.file?.defaultThreadEnvMode ?? null; const inheritedEnvModeLabel = - workspaceSource === "project" + settings.defaultThreadEnvMode !== null ? null : repositoryEnvMode ? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)` - : null; + : resolveEnvModeLabel("local"); function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); @@ -292,44 +291,34 @@ 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 - } - resetAction={ - settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? ( - - updateSettings({ - defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, - }) - } - /> - ) : null + ? "Where new threads in this project start. Inherit follows the environment, then the repository's t3.json." + : "Where new threads start. Inherit follows the repository's t3.json, or the current checkout." } + status={inheritedEnvModeLabel ? `Resolves to: ${inheritedEnvModeLabel}` : undefined} control={ { ["Off", false], ]); }); + + it("shows the checkout's t3.json as a layer for file-backed keys", () => { + const file = { defaultThreadEnvMode: "worktree" as const }; + const fromFile = settingInheritanceLayers( + { + environmentId, + label: "Laptop", + projectId, + ...resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, file), + }, + DEFAULT_SERVER_SETTINGS, + "defaultThreadEnvMode", + ); + expect(fromFile.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([ + ["Project", "Inherits", false], + ["Laptop", "Inherits", false], + ["t3.json", "New worktree", true], + ["Default", "Current checkout", false], + ]); + const settings = { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "local" as const }; + const fromEnvironment = settingInheritanceLayers( + { + environmentId, + label: "Laptop", + projectId, + ...resolveProjectSettings(settings, projectId, null, file), + }, + settings, + "defaultThreadEnvMode", + ); + expect(fromEnvironment.map((layer) => [layer.value, layer.effective])).toEqual([ + ["Inherits", false], + ["Current checkout", true], + ["Inherits", false], + ["Current checkout", false], + ]); + }); }); diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index 42d4962fda68..623e83056155 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -1,5 +1,7 @@ import { DEFAULT_SERVER_SETTINGS, + PROJECT_FILE_BACKED_SETTINGS, + type ProjectFileBackedSettingKey, resolveEnvironmentMachineKind, type ServerSettings, } from "@t3tools/contracts"; @@ -18,7 +20,7 @@ import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSetting import { isProjectScopedSettingKey } from "./scopedSettings"; interface InheritanceLayer { - readonly key: "project" | "environment" | "built-in"; + readonly key: "project" | "environment" | "t3.json" | "built-in"; readonly label: string; readonly value: string; readonly effective: boolean; @@ -76,44 +78,65 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { /** * The layers a setting resolves through for one target, top-down: the * project override when the target is a project, the environment's value, - * and the built-in default. The first layer that is set wins. + * the checkout's t3.json for file-backed keys, and the built-in default. The + * first layer that is set wins. Same order as `resolveProjectSettings`. */ export function settingInheritanceLayers( target: ScopedSettingsTarget, environmentSettings: ServerSettings, key: keyof ServerSettings, ): readonly InheritanceLayer[] { - const builtIn = DEFAULT_SERVER_SETTINGS[key]; const environmentValue = environmentSettings[key]; - const projectSource = isProjectScopedSettingKey(key) ? target.sources[key] : "environment"; - const environmentSet = !Equal.equals(environmentValue, builtIn); + const source = isProjectScopedSettingKey(key) ? target.sources[key] : "environment"; + const environmentSet = !Equal.equals(environmentValue, DEFAULT_SERVER_SETTINGS[key]); + const fileBacked = isProjectFileBackedSettingKey(key); const layers: InheritanceLayer[] = []; if (target.projectId !== null && isProjectScopedSettingKey(key)) { layers.push({ key: "project", label: "Project", - value: projectSource === "project" ? formatValue(key, target.settings[key]) : "Inherits", - effective: projectSource === "project", - set: projectSource === "project", + value: source === "project" ? formatValue(key, target.settings[key]) : "Inherits", + effective: source === "project", + set: source === "project", }); } layers.push({ key: "environment", label: target.label, value: environmentSet ? formatValue(key, environmentValue) : "Inherits", - effective: projectSource !== "project" && environmentSet, + effective: source === "environment" && environmentSet, set: environmentSet, }); + if (fileBacked && target.projectId !== null) { + layers.push({ + key: "t3.json", + label: "t3.json", + value: source === "t3.json" ? formatValue(key, target.settings[key]) : "Inherits", + effective: source === "t3.json", + set: source === "t3.json", + }); + } + // For a file-backed key the built-in is what the resolver produced with + // nothing set, not the null the schema decodes to. + const builtIn = fileBacked + ? PROJECT_FILE_BACKED_SETTINGS[key].builtIn + : DEFAULT_SERVER_SETTINGS[key]; layers.push({ key: "built-in", label: "Default", value: formatValue(key, builtIn), - effective: projectSource !== "project" && !environmentSet, + effective: source === "environment" && !environmentSet, set: true, }); return layers; } +function isProjectFileBackedSettingKey( + key: keyof ServerSettings, +): key is ProjectFileBackedSettingKey { + return Object.hasOwn(PROJECT_FILE_BACKED_SETTINGS, key); +} + export type SettingInheritanceState = | "default" | "environment" diff --git a/apps/web/src/components/settings/SettingsScopeContext.tsx b/apps/web/src/components/settings/SettingsScopeContext.tsx index c758b29272dd..543001e79207 100644 --- a/apps/web/src/components/settings/SettingsScopeContext.tsx +++ b/apps/web/src/components/settings/SettingsScopeContext.tsx @@ -1,22 +1,70 @@ +import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { useAtomValue } from "@effect/atom-react"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createContext, type ReactNode, useContext, useMemo } from "react"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { getProjectFileQueryAtom } from "../files/projectFilesQueryState"; import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings"; import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +/** + * Each member's decoded t3.json, so file-backed settings show the file as a + * layer in the inheritance chain. A member is only present once its read has + * settled; the query atom caches per (environment, cwd). + */ +function useMemberProjectFiles(scope: ReturnType) { + const members = scope.kind === "project" || scope.kind === "checkout" ? scope.members : []; + return useAtomValue( + useMemo( + () => + Atom.make((get) => { + const files = new Map(); + for (const member of members) { + const result = get( + getProjectFileQueryAtom( + member.environmentId, + member.workspaceRoot, + T3_PROJECT_FILE_NAME, + ), + ); + if (result.waiting) continue; + const data = Option.getOrNull(AsyncResult.value(result)); + files.set( + member.physicalProjectKey, + data === null || data.truncated ? null : parseT3ProjectFile(data.contents), + ); + } + return files; + }), + [members], + ), + ); +} + function useResolvedSettingsScope(search: SettingsScopeSearch) { const groups = useSettingsProjectGroups(); const { environments: availableEnvironments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const scope = useMemo( + () => resolveSettingsScope(search, groups, availableEnvironments), + [availableEnvironments, groups, search], + ); + const projectFiles = useMemberProjectFiles(scope); return useMemo(() => { - const scope = resolveSettingsScope(search, groups, availableEnvironments); const selected = selectScopedSettingsEnvironments( scope, availableEnvironments, primaryEnvironmentId, ); - const targets = resolveScopedSettingsTargets(scope, selected.connectedEnvironments); + const targets = resolveScopedSettingsTargets( + scope, + selected.connectedEnvironments, + projectFiles, + ); // The representative target supplies display values; project scopes // prefer the member on the primary environment, like environments do. const target = @@ -26,7 +74,7 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) { targets[0] ?? null; return { scope, groups, ...selected, targets, target }; - }, [availableEnvironments, groups, primaryEnvironmentId, search]); + }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope]); } const SettingsScopeContext = createContext< diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 86153712b6da..e1e884632358 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -8,6 +8,7 @@ import { type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, ServerSettings, + type T3ProjectFile, type ServerSettingsPatch, } from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; @@ -86,6 +87,9 @@ export interface ScopedSettingsTarget { export function resolveScopedSettingsTargets( scope: ResolvedSettingsScope, connectedEnvironments: readonly ScopedSettingsEnvironment[], + // Each member's decoded t3.json, keyed by physical project key, once read. + // A member absent here has no file tier yet; null is a missing or invalid file. + projectFiles?: ReadonlyMap, ): readonly ScopedSettingsTarget[] { const byId = new Map( connectedEnvironments.map((environment) => [environment.environmentId, environment]), @@ -94,7 +98,11 @@ export function resolveScopedSettingsTargets( return scope.members.flatMap((member) => { const environment = byId.get(member.environmentId); if (!environment?.serverConfig) return []; - const resolved = resolveProjectSettings(environment.serverConfig.settings, member.id); + const projectFile = projectFiles?.get(member.physicalProjectKey); + const resolved = + projectFile === undefined + ? resolveProjectSettings(environment.serverConfig.settings, member.id) + : resolveProjectSettings(environment.serverConfig.settings, member.id, null, projectFile); return [ { environmentId: member.environmentId, @@ -144,7 +152,13 @@ export function scopedSettingsSource( const scoped = keys.filter(isProjectScopedSettingKey); if (scoped.length === 0 || targets.length === 0) return "environment"; const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key]))); - return sources.size > 1 ? "mixed" : sources.has("project") ? "project" : "environment"; + return sources.size > 1 + ? "mixed" + : sources.has("project") + ? "project" + : sources.has("t3.json") + ? "t3.json" + : "environment"; } interface ScopedServerWrite { diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index c805e37f1bbd..6691b93a7242 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -407,11 +407,13 @@ export function SettingsRow({ ? { state: "mixed", summary: "Mixed across selected environments" } : source === "project" ? { state: "overridden", summary: "Overridden for this project" } - : source === "environment" && scopedKeys.length > 0 - ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` } - : customized - ? { state: "environment", summary: "Set on the environment" } - : { state: "default", summary: "Built-in default" }; + : source === "t3.json" + ? { state: "inherited", summary: "Inherited from the repository's t3.json" } + : source === "environment" && scopedKeys.length > 0 + ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` } + : customized + ? { state: "environment", summary: "Set on the environment" } + : { state: "default", summary: "Built-in default" }; const renderedInheritance = context && serverScoped && settingKeys.length > 0 ? ( Date: Mon, 21 Sep 2026 17:40:13 -0700 Subject: [PATCH 8/9] fix(web): settings targets see a pending t3.json save Co-Authored-By: Claude Fable 5 --- .../src/components/files/projectFilesQueryState.ts | 7 ++++++- .../src/components/settings/SettingsScopeContext.tsx | 12 ++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index b9a880301831..a6a556231e18 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/SettingsScopeContext.tsx b/apps/web/src/components/settings/SettingsScopeContext.tsx index 543001e79207..1602c29d76e2 100644 --- a/apps/web/src/components/settings/SettingsScopeContext.tsx +++ b/apps/web/src/components/settings/SettingsScopeContext.tsx @@ -6,7 +6,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createContext, type ReactNode, useContext, useMemo } from "react"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; -import { getProjectFileQueryAtom } from "../files/projectFilesQueryState"; +import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFilesQueryState"; import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings"; import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; @@ -32,7 +32,15 @@ function useMemberProjectFiles(scope: ReturnType) { ), ); if (result.waiting) continue; - const data = Option.getOrNull(AsyncResult.value(result)); + // A pending in-app save overlays the query, like useProjectFileQuery. + const data = + get( + optimisticFileAtom( + member.environmentId, + member.workspaceRoot, + T3_PROJECT_FILE_NAME, + ), + )?.data ?? Option.getOrNull(AsyncResult.value(result)); files.set( member.physicalProjectKey, data === null || data.truncated ? null : parseT3ProjectFile(data.contents), From ffb09aeab00bdb7bb02e8695c2fc52cf0d20e39e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:46:31 -0700 Subject: [PATCH 9/9] fix(settings): pick the workspace value, reset to inherit No Inherit item in the picker: the row shows the effective value and the reset arrow clears the tier, like every other row. Mobile keeps Inherit only at environment scope, where it has no other way back. Co-Authored-By: Claude Fable 5 --- .../SettingsServerControlsRouteScreen.tsx | 7 ++-- .../settings/ProjectDefaultsSettings.tsx | 35 +++++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 35ebb564084e..94312207714a 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -54,10 +54,11 @@ const WORKSPACE_CHOICES: ReadonlyArray<{ readonly label: string; readonly description: string; }> = [ + // Only offered at environment scope; a project falls back through "Use defaults". { mode: null, label: "Inherit", - description: "Use the environment's setting or the repository's t3.json.", + description: "Use the repository's t3.json, or the current checkout.", }, { mode: "local", @@ -233,7 +234,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null } > - {WORKSPACE_CHOICES.map((choice, index) => ( + {WORKSPACE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( entry.instanceId === instanceId); @@ -276,30 +285,34 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ? "Where new threads in this project start." : "Where new threads start. Projects and their t3.json can override it." } + resetAction={ + !isProjectScope && settings.defaultThreadEnvMode !== null ? ( + updateSettings({ defaultThreadEnvMode: null })} + /> + ) : null + } control={