authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-21 18:15:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-21 18:15:07-07:00
log742173a13250bcba880b700aaead34e1b0810dff
tree39ea1f629e366c935d8dac6648e0f952078fd316
parent1262d2f3ab2940c1d47ca4f5c4ca42e5c7259765
signature Signed by PGP key B5690EEEBB952194

feat(settings): resolve t3.json inside the project settings resolver (#12954)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

25 files changed, 615 insertions(+), 236 deletions(-)

apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+20-5
...@@ -50,10 +50,16 @@ const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettin...@@ -50,10 +50,16 @@ const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettin
50};50};
5151
52const WORKSPACE_CHOICES: ReadonlyArray<{52const WORKSPACE_CHOICES: ReadonlyArray<{
53 readonly mode: ThreadEnvMode;53 readonly mode: ThreadEnvMode | null;
54 readonly label: string;54 readonly label: string;
55 readonly description: string;55 readonly description: string;
56}> = [56}> = [
57 // Only offered at environment scope; a project falls back through "Use defaults".
58 {
59 mode: null,
60 label: "Inherit",
61 description: "Use the repository's t3.json, or the current checkout.",
62 },
57 {63 {
58 mode: "local",64 mode: "local",
59 label: "Current checkout",65 label: "Current checkout",
...@@ -126,6 +132,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -126,6 +132,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
126 const value = reference.settings[key];132 const value = reference.settings[key];
127 return displayTargets.every((entry) => entry.settings[key] === value) ? value : null;133 return displayTargets.every((entry) => entry.settings[key] === value) ? value : null;
128 };134 };
135 // `uniform` folds a real null into "mixed"; nullable keys need the distinction.
136 const isMixed = (key: keyof ServerSettings) =>
137 reference === null ||
138 displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]);
129 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {139 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
130 label: "environment settings update",140 label: "environment settings update",
131 reportFailure: true,141 reportFailure: true,
...@@ -219,17 +229,22 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -219,17 +229,22 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
219 <SettingsSection229 <SettingsSection
220 title="Default workspace"230 title="Default workspace"
221 trailing={231 trailing={
222 pendingWrites === 0 && uniform("defaultThreadEnvMode") === null ? (232 pendingWrites === 0 && isMixed("defaultThreadEnvMode") ? (
223 <MixedValuesLabel projectSelected={projectSelected} />233 <MixedValuesLabel projectSelected={projectSelected} />
224 ) : null234 ) : null
225 }235 }
226 >236 >
227 {WORKSPACE_CHOICES.map((choice, index) => (237 {WORKSPACE_CHOICES.filter(
238 (choice) => choice.mode !== null || !projectSelected,
239 ).map((choice, index) => (
228 <ChoiceRow240 <ChoiceRow
229 key={choice.mode}241 key={choice.mode ?? "inherit"}
230 label={choice.label}242 label={choice.label}
231 description={choice.description}243 description={choice.description}
232 selected={uniform("defaultThreadEnvMode") === choice.mode}244 selected={
245 !isMixed("defaultThreadEnvMode") &&
246 uniform("defaultThreadEnvMode") === choice.mode
247 }
233 separated={index > 0}248 separated={index > 0}
234 disabled={disabledFor("defaultThreadEnvMode")}249 disabled={disabledFor("defaultThreadEnvMode")}
235 onPress={() => write({ defaultThreadEnvMode: choice.mode })}250 onPress={() => write({ defaultThreadEnvMode: choice.mode })}
apps/mobile/src/features/settings/settings-scoped-server.test.ts+33
...@@ -69,6 +69,39 @@ describe("mobile project settings scope", () => {...@@ -69,6 +69,39 @@ describe("mobile project settings scope", () => {
69 expect(secondSettings.responseStreamingMode).toBe("token");69 expect(secondSettings.responseStreamingMode).toBe("token");
70 });70 });
7171
72 it("removes a project override when a picker sends null for a key that cannot store it", () => {
73 const settings: ServerSettings = {
74 ...DEFAULT_SERVER_SETTINGS,
75 projectSettingsOverrides: {
76 [firstProject]: { defaultThreadEnvMode: "worktree", defaultAutoPull: true },
77 },
78 };
79 const targets = resolveMobileSettingsTargets(
80 [environment(firstId, settings)],
81 [{ environmentId: firstId, id: firstProject }],
82 );
83 expect(planMobileScopedSettingsPatch(targets, true, { defaultThreadEnvMode: null })).toEqual([
84 {
85 environmentId: firstId,
86 patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } },
87 },
88 ]);
89 expect(planMobileScopedSettingsPatch(targets, true, { defaultModelSelection: null })).toEqual([
90 {
91 environmentId: firstId,
92 patch: {
93 projectSettingsOverrides: {
94 [firstProject]: {
95 defaultThreadEnvMode: "worktree",
96 defaultAutoPull: true,
97 defaultModelSelection: null,
98 },
99 },
100 },
101 },
102 ]);
103 });
104
72 it("resets only the selected page's override and rejects environment-wide writes", () => {105 it("resets only the selected page's override and rejects environment-wide writes", () => {
73 const settings: ServerSettings = {106 const settings: ServerSettings = {
74 ...DEFAULT_SERVER_SETTINGS,107 ...DEFAULT_SERVER_SETTINGS,
apps/mobile/src/features/settings/settings-scoped-server.ts+14-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 isNullableProjectSettingsOverride,
2 PROJECT_SCOPED_SERVER_SETTING_KEYS,3 PROJECT_SCOPED_SERVER_SETTING_KEYS,
3 type EnvironmentId,4 type EnvironmentId,
4 type ProjectId,5 type ProjectId,
...@@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch(...@@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch(
69 continue;70 continue;
70 const current =71 const current =
71 target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {};72 target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {};
72 const next = { ...current, ...patch };73 const next: Record<string, unknown> = { ...current };
74 for (const [key, value] of Object.entries(patch)) {
75 // A picker's "Inherit" sends null; for keys whose override cannot
76 // store null that means remove the override.
77 if (
78 value === null &&
79 !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey)
80 ) {
81 delete next[key];
82 } else {
83 next[key] = value;
84 }
85 }
73 const overrides = writes.get(target.environment.environmentId) ?? {};86 const overrides = writes.get(target.environment.environmentId) ?? {};
74 overrides[target.projectId] = next;87 overrides[target.projectId] = next;
75 writes.set(target.environment.environmentId, overrides);88 writes.set(target.environment.environmentId, overrides);
apps/mobile/src/features/threads/new-task-flow-provider.tsx+21-28
...@@ -20,10 +20,6 @@ import {...@@ -20,10 +20,6 @@ import {
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { resolveProjectSettings } from "@t3tools/shared/projectSettings";21import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
22import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";22import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
23import {
24 isDefaultThreadEnvModeSettled,
25 resolveDefaultThreadEnvMode,
26} from "@t3tools/shared/threadEnvMode";
27import * as Arr from "effect/Array";23import * as Arr from "effect/Array";
28import { pipe } from "effect/Function";24import { pipe } from "effect/Function";
2925
...@@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
431 : null,427 : null,
432 );428 );
433 const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null;429 const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null;
434 const t3ProjectFileDefaultMode = useMemo(() => {430 const t3ProjectFile = useMemo(
435 if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null;431 () =>
436 return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null;432 t3ProjectFileData === null || t3ProjectFileData.truncated
437 }, [t3ProjectFileData]);433 ? null
438 // Environment settings with the project's overrides applied; the434 : parseT3ProjectFile(t3ProjectFileData.contents),
439 // aggregate's own legacy fields still count until the server folds them.435 [t3ProjectFileData],
436 );
437 // Environment settings with the project's overrides and its t3.json
438 // applied; the aggregate's own legacy fields still count until the server
439 // folds them.
440 const projectSettings = useMemo(440 const projectSettings = useMemo(
441 () =>441 () =>
442 resolveProjectSettings(442 resolveProjectSettings(
443 selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS,443 selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
444 selectedProject?.id ?? null,444 selectedProject?.id ?? null,
445 selectedProject,445 selectedProject,
446 t3ProjectFile,
446 ),447 ),
447 [selectedEnvironmentServerConfig?.settings, selectedProject],448 [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile],
448 );449 );
449 const projectThreadEnvMode =450 const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode;
450 projectSettings.sources.defaultThreadEnvMode === "project"451 // While the file read is pending and nothing above it decided, the
451 ? projectSettings.settings.defaultThreadEnvMode452 // resolved default is provisional. Nothing may write it into the draft
452 : undefined;453 // during that window (the auto-branch effect does), or the frozen interim
453 const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({454 // value beats the t3.json default once it loads.
454 projectSetting: projectThreadEnvMode,455 const defaultWorkspaceModeSettled =
455 projectFile: t3ProjectFileDefaultMode,456 selectedProjectDraft.workspaceSelection?.mode !== undefined ||
456 globalDefault: projectSettings.settings.defaultThreadEnvMode,457 projectSettings.sources.defaultThreadEnvMode !== "environment" ||
457 });458 !t3ProjectFileQuery.isPending;
458 // While unsettled the resolved default is provisional. Nothing may write
459 // it into the draft during that window (the auto-branch effect does), or
460 // the frozen interim value beats the t3.json default once it loads.
461 const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({
462 explicitMode: selectedProjectDraft.workspaceSelection?.mode,
463 projectSetting: projectThreadEnvMode,
464 projectFilePending: t3ProjectFileQuery.isPending,
465 });
466 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;459 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
467 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;460 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
468 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;461 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
apps/web/src/components/files/projectFilesQueryState.ts+6-1
...@@ -20,7 +20,12 @@ const EMPTY_PROJECT_FILE_PATH = "";...@@ -20,7 +20,12 @@ const EMPTY_PROJECT_FILE_PATH = "";
20const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make(20const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make(
21 AsyncResult.initial<ProjectReadFileResult, never>(false),21 AsyncResult.initial<ProjectReadFileResult, never>(false),
22).pipe(Atom.withLabel("project-file-query:empty"));22).pipe(Atom.withLabel("project-file-query:empty"));
23function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) {23/** A pending in-app write to the file, overlaying the query until confirmed. */
24export function optimisticFileAtom(
25 environmentId: EnvironmentId,
26 cwd: string,
27 relativePath: string,
28) {
24 return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });29 return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });
25}30}
2631
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+14-31
...@@ -1,13 +1,12 @@...@@ -1,13 +1,12 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,
4 type ModelSelection,3 type ModelSelection,
5 type ProviderInstanceId,4 type ProviderInstanceId,
6} from "@t3tools/contracts";5} from "@t3tools/contracts";
7import { createModelSelection } from "@t3tools/shared/model";6import { createModelSelection } from "@t3tools/shared/model";
7import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
8import { useNavigate } from "@tanstack/react-router";8import { useNavigate } from "@tanstack/react-router";
99
10import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
11import { getCustomModelOptionsByInstance } from "../../modelSelection";10import { getCustomModelOptionsByInstance } from "../../modelSelection";
12import {11import {
13 applyProviderInstanceSettings,12 applyProviderInstanceSettings,
...@@ -75,25 +74,16 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -75,25 +74,16 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
75 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);74 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
76 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);75 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
77 const modelSource = useScopedSettingSource(["defaultModelSelection"]);76 const modelSource = useScopedSettingSource(["defaultModelSelection"]);
78 const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]);
79 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";77 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
80 const unavailable = connectedEnvironments.length === 0;78 const unavailable = connectedEnvironments.length === 0;
8179 // File-backed keys show their effective value; the target already carries
82 // A checkout's t3.json wins over the environment default when the project80 // the checkout's t3.json, and a null file here only fills the built-in.
83 // has no override of its own; show which one "inherit" resolves to.81 // The reset arrow beside the title clears the tier (SettingsRow handles a
84 const checkout = scope.kind === "checkout" ? scope.checkout : null;82 // project override, the environment value is cleared here), so the picker
85 // The query is disabled without a checkout, so any id satisfies the hook.83 // has no "inherit" item.
86 const t3File = useT3ProjectFileState(84 const effective = target
87 checkout?.environmentId ?? EnvironmentId.make("none"),85 ? resolveProjectSettings(target.settings, null, null, null).settings
88 category === "general" && checkout ? checkout.workspaceRoot : null,86 : null;
89 );
90 const repositoryEnvMode = t3File.file?.defaultThreadEnvMode ?? null;
91 const inheritedEnvModeLabel =
92 workspaceSource === "project"
93 ? null
94 : repositoryEnvMode
95 ? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)`
96 : null;
9787
98 function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {88 function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {
99 const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);89 const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
...@@ -292,27 +282,20 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -292,27 +282,20 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
292 title="Workspace"282 title="Workspace"
293 description={283 description={
294 isProjectScope284 isProjectScope
295 ? "Where new threads in this project start. A t3.json preference applies when the project has no override."285 ? "Where new threads in this project start."
296 : "Where new threads start, unless overridden by the project or t3.json."286 : "Where new threads start. Projects and their t3.json can override it."
297 }
298 status={
299 inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined
300 }287 }
301 resetAction={288 resetAction={
302 settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? (289 !isProjectScope && settings.defaultThreadEnvMode !== null ? (
303 <SettingResetButton290 <SettingResetButton
304 label="default workspace"291 label="default workspace"
305 onClick={() =>292 onClick={() => updateSettings({ defaultThreadEnvMode: null })}
306 updateSettings({
307 defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode,
308 })
309 }
310 />293 />
311 ) : null294 ) : null
312 }295 }
313 control={296 control={
314 <Select297 <Select
315 value={mixedWorkspace ? null : settings.defaultThreadEnvMode}298 value={mixedWorkspace ? null : (effective?.defaultThreadEnvMode ?? null)}
316 onValueChange={(value) => {299 onValueChange={(value) => {
317 if (value === "local" || value === "worktree")300 if (value === "local" || value === "worktree")
318 updateSettings({ defaultThreadEnvMode: value });301 updateSettings({ defaultThreadEnvMode: value });
apps/web/src/components/settings/SettingInheritance.test.ts+37
...@@ -54,4 +54,41 @@ describe("settingInheritanceLayers", () => {...@@ -54,4 +54,41 @@ describe("settingInheritanceLayers", () => {
54 ["Off", false],54 ["Off", false],
55 ]);55 ]);
56 });56 });
57
58 it("shows the checkout's t3.json as a layer for file-backed keys", () => {
59 const file = { defaultThreadEnvMode: "worktree" as const };
60 const fromFile = settingInheritanceLayers(
61 {
62 environmentId,
63 label: "Laptop",
64 projectId,
65 ...resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, file),
66 },
67 DEFAULT_SERVER_SETTINGS,
68 "defaultThreadEnvMode",
69 );
70 expect(fromFile.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([
71 ["Project", "Inherits", false],
72 ["Laptop", "Inherits", false],
73 ["t3.json", "New worktree", true],
74 ["Default", "Current checkout", false],
75 ]);
76 const settings = { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "local" as const };
77 const fromEnvironment = settingInheritanceLayers(
78 {
79 environmentId,
80 label: "Laptop",
81 projectId,
82 ...resolveProjectSettings(settings, projectId, null, file),
83 },
84 settings,
85 "defaultThreadEnvMode",
86 );
87 expect(fromEnvironment.map((layer) => [layer.value, layer.effective])).toEqual([
88 ["Inherits", false],
89 ["Current checkout", true],
90 ["Inherits", false],
91 ["Current checkout", false],
92 ]);
93 });
57});94});
apps/web/src/components/settings/SettingInheritance.tsx+36-11
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 PROJECT_FILE_BACKED_SETTINGS,
4 type ProjectFileBackedSettingKey,
3 resolveEnvironmentMachineKind,5 resolveEnvironmentMachineKind,
4 type ServerSettings,6 type ServerSettings,
5} from "@t3tools/contracts";7} from "@t3tools/contracts";
...@@ -18,7 +20,7 @@ import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSetting...@@ -18,7 +20,7 @@ import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSetting
18import { isProjectScopedSettingKey } from "./scopedSettings";20import { isProjectScopedSettingKey } from "./scopedSettings";
1921
20interface InheritanceLayer {22interface InheritanceLayer {
21 readonly key: "project" | "environment" | "built-in";23 readonly key: "project" | "environment" | "t3.json" | "built-in";
22 readonly label: string;24 readonly label: string;
23 readonly value: string;25 readonly value: string;
24 readonly effective: boolean;26 readonly effective: boolean;
...@@ -42,7 +44,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {...@@ -42,7 +44,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
42 ? "Automatic"44 ? "Automatic"
43 : key === "sourceControlWriterModelSelection"45 : key === "sourceControlWriterModelSelection"
44 ? "Text generation model"46 ? "Text generation model"
45 : "Not set";47 : key === "defaultThreadEnvMode"
48 ? "Inherit"
49 : "Not set";
46 }50 }
47 if (typeof value === "boolean") return value ? "On" : "Off";51 if (typeof value === "boolean") return value ? "On" : "Off";
48 if (typeof value === "number") {52 if (typeof value === "number") {
...@@ -74,44 +78,65 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {...@@ -74,44 +78,65 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
74/**78/**
75 * The layers a setting resolves through for one target, top-down: the79 * The layers a setting resolves through for one target, top-down: the
76 * project override when the target is a project, the environment's value,80 * project override when the target is a project, the environment's value,
77 * and the built-in default. The first layer that is set wins.81 * the checkout's t3.json for file-backed keys, and the built-in default. The
82 * first layer that is set wins. Same order as `resolveProjectSettings`.
78 */83 */
79export function settingInheritanceLayers(84export function settingInheritanceLayers(
80 target: ScopedSettingsTarget,85 target: ScopedSettingsTarget,
81 environmentSettings: ServerSettings,86 environmentSettings: ServerSettings,
82 key: keyof ServerSettings,87 key: keyof ServerSettings,
83): readonly InheritanceLayer[] {88): readonly InheritanceLayer[] {
84 const builtIn = DEFAULT_SERVER_SETTINGS[key];
85 const environmentValue = environmentSettings[key];89 const environmentValue = environmentSettings[key];
86 const projectSource = isProjectScopedSettingKey(key) ? target.sources[key] : "environment";90 const source = isProjectScopedSettingKey(key) ? target.sources[key] : "environment";
87 const environmentSet = !Equal.equals(environmentValue, builtIn);91 const environmentSet = !Equal.equals(environmentValue, DEFAULT_SERVER_SETTINGS[key]);
92 const fileBacked = isProjectFileBackedSettingKey(key);
88 const layers: InheritanceLayer[] = [];93 const layers: InheritanceLayer[] = [];
89 if (target.projectId !== null && isProjectScopedSettingKey(key)) {94 if (target.projectId !== null && isProjectScopedSettingKey(key)) {
90 layers.push({95 layers.push({
91 key: "project",96 key: "project",
92 label: "Project",97 label: "Project",
93 value: projectSource === "project" ? formatValue(key, target.settings[key]) : "Inherits",98 value: source === "project" ? formatValue(key, target.settings[key]) : "Inherits",
94 effective: projectSource === "project",99 effective: source === "project",
95 set: projectSource === "project",100 set: source === "project",
96 });101 });
97 }102 }
98 layers.push({103 layers.push({
99 key: "environment",104 key: "environment",
100 label: target.label,105 label: target.label,
101 value: environmentSet ? formatValue(key, environmentValue) : "Inherits",106 value: environmentSet ? formatValue(key, environmentValue) : "Inherits",
102 effective: projectSource !== "project" && environmentSet,107 effective: source === "environment" && environmentSet,
103 set: environmentSet,108 set: environmentSet,
104 });109 });
110 if (fileBacked && target.projectId !== null) {
111 layers.push({
112 key: "t3.json",
113 label: "t3.json",
114 value: source === "t3.json" ? formatValue(key, target.settings[key]) : "Inherits",
115 effective: source === "t3.json",
116 set: source === "t3.json",
117 });
118 }
119 // For a file-backed key the built-in is what the resolver produced with
120 // nothing set, not the null the schema decodes to.
121 const builtIn = fileBacked
122 ? PROJECT_FILE_BACKED_SETTINGS[key].builtIn
123 : DEFAULT_SERVER_SETTINGS[key];
105 layers.push({124 layers.push({
106 key: "built-in",125 key: "built-in",
107 label: "Default",126 label: "Default",
108 value: formatValue(key, builtIn),127 value: formatValue(key, builtIn),
109 effective: projectSource !== "project" && !environmentSet,128 effective: source === "environment" && !environmentSet,
110 set: true,129 set: true,
111 });130 });
112 return layers;131 return layers;
113}132}
114133
134function isProjectFileBackedSettingKey(
135 key: keyof ServerSettings,
136): key is ProjectFileBackedSettingKey {
137 return Object.hasOwn(PROJECT_FILE_BACKED_SETTINGS, key);
138}
139
115export type SettingInheritanceState =140export type SettingInheritanceState =
116 | "default"141 | "default"
117 | "environment"142 | "environment"
apps/web/src/components/settings/SettingsScopeContext.tsx+59-3
...@@ -1,22 +1,78 @@...@@ -1,22 +1,78 @@
1import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts";
2import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
3import { useAtomValue } from "@effect/atom-react";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";
1import { createContext, type ReactNode, useContext, useMemo } from "react";6import { createContext, type ReactNode, useContext, useMemo } from "react";
27
3import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";8import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
9import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFilesQueryState";
4import { useSettingsProjectGroups } from "./useSettingsProjectGroups";10import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
5import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";11import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";
6import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";12import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
713
14/**
15 * Each member's decoded t3.json, so file-backed settings show the file as a
16 * layer in the inheritance chain. A member is only present once its read has
17 * settled; the query atom caches per (environment, cwd).
18 */
19function useMemberProjectFiles(scope: ReturnType<typeof resolveSettingsScope>) {
20 const members = scope.kind === "project" || scope.kind === "checkout" ? scope.members : [];
21 return useAtomValue(
22 useMemo(
23 () =>
24 Atom.make((get) => {
25 const files = new Map<string, T3ProjectFile | null>();
26 for (const member of members) {
27 const result = get(
28 getProjectFileQueryAtom(
29 member.environmentId,
30 member.workspaceRoot,
31 T3_PROJECT_FILE_NAME,
32 ),
33 );
34 if (result.waiting) continue;
35 // A pending in-app save overlays the query, like useProjectFileQuery.
36 const data =
37 get(
38 optimisticFileAtom(
39 member.environmentId,
40 member.workspaceRoot,
41 T3_PROJECT_FILE_NAME,
42 ),
43 )?.data ?? Option.getOrNull(AsyncResult.value(result));
44 files.set(
45 member.physicalProjectKey,
46 data === null || data.truncated ? null : parseT3ProjectFile(data.contents),
47 );
48 }
49 return files;
50 }),
51 [members],
52 ),
53 );
54}
55
8function useResolvedSettingsScope(search: SettingsScopeSearch) {56function useResolvedSettingsScope(search: SettingsScopeSearch) {
9 const groups = useSettingsProjectGroups();57 const groups = useSettingsProjectGroups();
10 const { environments: availableEnvironments } = useEnvironments();58 const { environments: availableEnvironments } = useEnvironments();
11 const primaryEnvironmentId = usePrimaryEnvironmentId();59 const primaryEnvironmentId = usePrimaryEnvironmentId();
60 const scope = useMemo(
61 () => resolveSettingsScope(search, groups, availableEnvironments),
62 [availableEnvironments, groups, search],
63 );
64 const projectFiles = useMemberProjectFiles(scope);
12 return useMemo(() => {65 return useMemo(() => {
13 const scope = resolveSettingsScope(search, groups, availableEnvironments);
14 const selected = selectScopedSettingsEnvironments(66 const selected = selectScopedSettingsEnvironments(
15 scope,67 scope,
16 availableEnvironments,68 availableEnvironments,
17 primaryEnvironmentId,69 primaryEnvironmentId,
18 );70 );
19 const targets = resolveScopedSettingsTargets(scope, selected.connectedEnvironments);71 const targets = resolveScopedSettingsTargets(
72 scope,
73 selected.connectedEnvironments,
74 projectFiles,
75 );
20 // The representative target supplies display values; project scopes76 // The representative target supplies display values; project scopes
21 // prefer the member on the primary environment, like environments do.77 // prefer the member on the primary environment, like environments do.
22 const target =78 const target =
...@@ -26,7 +82,7 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) {...@@ -26,7 +82,7 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) {
26 targets[0] ??82 targets[0] ??
27 null;83 null;
28 return { scope, groups, ...selected, targets, target };84 return { scope, groups, ...selected, targets, target };
29 }, [availableEnvironments, groups, primaryEnvironmentId, search]);85 }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope]);
30}86}
3187
32const SettingsScopeContext = createContext<88const SettingsScopeContext = createContext<
apps/web/src/components/settings/scopedSettings.test.ts+47
...@@ -510,6 +510,53 @@ describe("project overrides at environment scope", () => {...@@ -510,6 +510,53 @@ describe("project overrides at environment scope", () => {
510 });510 });
511});511});
512512
513describe("null patches at project scope", () => {
514 it("removes the override for keys that cannot store null and keeps it for keys that can", () => {
515 const environmentId = EnvironmentId.make("laptop");
516 const projectId = ProjectId.make("fleet");
517 const scope = {
518 kind: "project" as const,
519 group: {} as never,
520 environmentId: null,
521 label: "fleet",
522 members: [{ id: projectId, environmentId } as never],
523 environmentIds: [environmentId],
524 };
525 const environments = [
526 {
527 environmentId,
528 label: "Laptop",
529 connection: { phase: "connected" as const },
530 serverConfig: {
531 settings: {
532 ...DEFAULT_SERVER_SETTINGS,
533 projectSettingsOverrides: {
534 [projectId]: { defaultThreadEnvMode: "worktree" as const, defaultAutoPull: true },
535 },
536 },
537 environment: { capabilities: { projectSettingsOverrides: true } },
538 },
539 },
540 ];
541 expect(
542 planScopedSettingsPatch(scope, environments, { defaultThreadEnvMode: null }).serverWrites[0]
543 ?.patch,
544 ).toEqual({ projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } } });
545 expect(
546 planScopedSettingsPatch(scope, environments, { defaultModelSelection: null }).serverWrites[0]
547 ?.patch,
548 ).toEqual({
549 projectSettingsOverrides: {
550 [projectId]: {
551 defaultThreadEnvMode: "worktree",
552 defaultAutoPull: true,
553 defaultModelSelection: null,
554 },
555 },
556 });
557 });
558});
559
513describe("partial object patches at project scope", () => {560describe("partial object patches at project scope", () => {
514 it("completes a writing style field patch from the target's effective value", () => {561 it("completes a writing style field patch from the target's effective value", () => {
515 const environmentId = EnvironmentId.make("laptop");562 const environmentId = EnvironmentId.make("laptop");
apps/web/src/components/settings/scopedSettings.ts+26-2
...@@ -2,11 +2,13 @@ import {...@@ -2,11 +2,13 @@ import {
2 ClientSettingsSchema,2 ClientSettingsSchema,
3 type ClientSettingsPatch,3 type ClientSettingsPatch,
4 type EnvironmentId,4 type EnvironmentId,
5 isNullableProjectSettingsOverride,
5 PROJECT_SCOPED_SERVER_SETTING_KEYS,6 PROJECT_SCOPED_SERVER_SETTING_KEYS,
6 type ProjectId,7 type ProjectId,
7 type ProjectScopedServerSettingKey,8 type ProjectScopedServerSettingKey,
8 type ProjectSettingsOverrides,9 type ProjectSettingsOverrides,
9 ServerSettings,10 ServerSettings,
11 type T3ProjectFile,
10 type ServerSettingsPatch,12 type ServerSettingsPatch,
11} from "@t3tools/contracts";13} from "@t3tools/contracts";
12import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";14import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
...@@ -85,6 +87,9 @@ export interface ScopedSettingsTarget {...@@ -85,6 +87,9 @@ export interface ScopedSettingsTarget {
85export function resolveScopedSettingsTargets(87export function resolveScopedSettingsTargets(
86 scope: ResolvedSettingsScope,88 scope: ResolvedSettingsScope,
87 connectedEnvironments: readonly ScopedSettingsEnvironment[],89 connectedEnvironments: readonly ScopedSettingsEnvironment[],
90 // Each member's decoded t3.json, keyed by physical project key, once read.
91 // A member absent here has no file tier yet; null is a missing or invalid file.
92 projectFiles?: ReadonlyMap<string, T3ProjectFile | null>,
88): readonly ScopedSettingsTarget[] {93): readonly ScopedSettingsTarget[] {
89 const byId = new Map(94 const byId = new Map(
90 connectedEnvironments.map((environment) => [environment.environmentId, environment]),95 connectedEnvironments.map((environment) => [environment.environmentId, environment]),
...@@ -93,7 +98,11 @@ export function resolveScopedSettingsTargets(...@@ -93,7 +98,11 @@ export function resolveScopedSettingsTargets(
93 return scope.members.flatMap((member) => {98 return scope.members.flatMap((member) => {
94 const environment = byId.get(member.environmentId);99 const environment = byId.get(member.environmentId);
95 if (!environment?.serverConfig) return [];100 if (!environment?.serverConfig) return [];
96 const resolved = resolveProjectSettings(environment.serverConfig.settings, member.id);101 const projectFile = projectFiles?.get(member.physicalProjectKey);
102 const resolved =
103 projectFile === undefined
104 ? resolveProjectSettings(environment.serverConfig.settings, member.id)
105 : resolveProjectSettings(environment.serverConfig.settings, member.id, null, projectFile);
97 return [106 return [
98 {107 {
99 environmentId: member.environmentId,108 environmentId: member.environmentId,
...@@ -143,7 +152,13 @@ export function scopedSettingsSource(...@@ -143,7 +152,13 @@ export function scopedSettingsSource(
143 const scoped = keys.filter(isProjectScopedSettingKey);152 const scoped = keys.filter(isProjectScopedSettingKey);
144 if (scoped.length === 0 || targets.length === 0) return "environment";153 if (scoped.length === 0 || targets.length === 0) return "environment";
145 const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key])));154 const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key])));
146 return sources.size > 1 ? "mixed" : sources.has("project") ? "project" : "environment";155 return sources.size > 1
156 ? "mixed"
157 : sources.has("project")
158 ? "project"
159 : sources.has("t3.json")
160 ? "t3.json"
161 : "environment";
147}162}
148163
149interface ScopedServerWrite {164interface ScopedServerWrite {
...@@ -234,6 +249,15 @@ export function planScopedSettingsPatch(...@@ -234,6 +249,15 @@ export function planScopedSettingsPatch(
234 };249 };
235 continue;250 continue;
236 }251 }
252 // A picker's "Inherit" sends null; for keys whose override
253 // cannot store null that means remove the override.
254 if (
255 value === null &&
256 !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey)
257 ) {
258 delete next[key];
259 continue;
260 }
237 const base = effective[key as keyof ServerSettings];261 const base = effective[key as keyof ServerSettings];
238 next[key] =262 next[key] =
239 isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;263 isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
apps/web/src/components/settings/settingsLayout.tsx+7-5
...@@ -407,11 +407,13 @@ export function SettingsRow({...@@ -407,11 +407,13 @@ export function SettingsRow({
407 ? { state: "mixed", summary: "Mixed across selected environments" }407 ? { state: "mixed", summary: "Mixed across selected environments" }
408 : source === "project"408 : source === "project"
409 ? { state: "overridden", summary: "Overridden for this project" }409 ? { state: "overridden", summary: "Overridden for this project" }
410 : source === "environment" && scopedKeys.length > 0410 : source === "t3.json"
411 ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` }411 ? { state: "inherited", summary: "Inherited from the repository's t3.json" }
412 : customized412 : source === "environment" && scopedKeys.length > 0
413 ? { state: "environment", summary: "Set on the environment" }413 ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` }
414 : { state: "default", summary: "Built-in default" };414 : customized
415 ? { state: "environment", summary: "Set on the environment" }
416 : { state: "default", summary: "Built-in default" };
415 const renderedInheritance =417 const renderedInheritance =
416 context && serverScoped && settingKeys.length > 0 ? (418 context && serverScoped && settingKeys.length > 0 ? (
417 <SettingInheritance419 <SettingInheritance
apps/web/src/hooks/useHandleNewThread.test.ts+16-9
...@@ -100,18 +100,25 @@ vi.mock("@t3tools/contracts", () => ({...@@ -100,18 +100,25 @@ vi.mock("@t3tools/contracts", () => ({
100vi.mock("@t3tools/shared/projectSettings", () => ({100vi.mock("@t3tools/shared/projectSettings", () => ({
101 // Environment settings pass through; the tests set project fields on the101 // Environment settings pass through; the tests set project fields on the
102 // project record, which the hook still honors until the server folds them.102 // project record, which the hook still honors until the server folds them.
103 resolveProjectSettings: (settings: Record<string, unknown>) => ({103 // With a file argument the env mode resolves like the real chain.
104 settings,104 resolveProjectSettings: (
105 settings: Record<string, unknown>,
106 _projectId: unknown,
107 _project: unknown,
108 projectFile?: { defaultThreadEnvMode?: "local" | "worktree" } | null,
109 ) => ({
110 settings:
111 projectFile === undefined
112 ? settings
113 : {
114 ...settings,
115 defaultThreadEnvMode:
116 settings.defaultThreadEnvMode ?? projectFile?.defaultThreadEnvMode ?? "local",
117 },
105 sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },118 sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },
106 overrides: {},119 overrides: {},
107 }),120 }),
108}));121}));
109vi.mock("@t3tools/shared/threadEnvMode", () => ({
110 resolveDefaultThreadEnvMode: (input: {
111 readonly projectFile: "local" | "worktree" | null;
112 readonly globalDefault: "local" | "worktree";
113 }) => input.projectFile ?? input.globalDefault,
114}));
115vi.mock("@tanstack/react-router", () => ({122vi.mock("@tanstack/react-router", () => ({
116 useParams: () => null,123 useParams: () => null,
117 useRouter: () => testState.router,124 useRouter: () => testState.router,
...@@ -137,7 +144,7 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({...@@ -137,7 +144,7 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({
137 resolveNewThreadModelSelectionOverride: () => null,144 resolveNewThreadModelSelectionOverride: () => null,
138}));145}));
139vi.mock("../lib/t3ProjectFileDefaults", () => ({146vi.mock("../lib/t3ProjectFileDefaults", () => ({
140 readT3ProjectFileDefaultThreadEnvMode: () => testState.projectFileRead,147 readT3ProjectFile: () => testState.projectFileRead,
141}));148}));
142vi.mock("../lib/utils", () => ({149vi.mock("../lib/utils", () => ({
143 newDraftId: () => "draft-delayed",150 newDraftId: () => "draft-delayed",
apps/web/src/hooks/useHandleNewThread.ts+12-17
...@@ -23,14 +23,13 @@ import {...@@ -23,14 +23,13 @@ import {
23 selectProjectGroupingSettings,23 selectProjectGroupingSettings,
24} from "../logicalProject";24} from "../logicalProject";
25import { resolveProjectSettings } from "@t3tools/shared/projectSettings";25import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
26import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
27import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";26import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
28import {27import {
29 hasExplicitComposerModelSelection,28 hasExplicitComposerModelSelection,
30 resolveNewDraftStartFromOrigin,29 resolveNewDraftStartFromOrigin,
31 resolveNewThreadModelSelectionOverride,30 resolveNewThreadModelSelectionOverride,
32} from "../lib/chatThreadActions";31} from "../lib/chatThreadActions";
33import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";32import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults";
34import { environmentServerConfigsAtom } from "../state/server";33import { environmentServerConfigsAtom } from "../state/server";
35import { resolveThreadRouteTarget } from "../threadRoutes";34import { resolveThreadRouteTarget } from "../threadRoutes";
36import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";35import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
...@@ -138,10 +137,6 @@ export function useNewThreadHandler() {...@@ -138,10 +137,6 @@ export function useNewThreadHandler() {
138 );137 );
139 const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection;138 const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection;
140 const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode;139 const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode;
141 const projectThreadEnvMode =
142 projectSettings.sources.defaultThreadEnvMode === "project"
143 ? projectSettings.settings.defaultThreadEnvMode
144 : undefined;
145 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>140 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
146 resolveNewThreadModelSelectionOverride({141 resolveNewThreadModelSelectionOverride({
147 projectDefaultSelection: projectDefaultModelSelection ?? null,142 projectDefaultSelection: projectDefaultModelSelection ?? null,
...@@ -154,17 +149,17 @@ export function useNewThreadHandler() {...@@ -154,17 +149,17 @@ export function useNewThreadHandler() {
154 // skipped entirely when a higher-priority source decides, and its149 // skipped entirely when a higher-priority source decides, and its
155 // query atom caches per project after the first call.150 // query atom caches per project after the first call.
156 const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {151 const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {
157 const consultProjectFile = project !== undefined && projectThreadEnvMode == null;152 const consultProjectFile =
158 return resolveDefaultThreadEnvMode({153 project !== undefined && projectSettings.settings.defaultThreadEnvMode === null;
159 projectSetting: projectThreadEnvMode,154 const projectFile = consultProjectFile
160 projectFile: consultProjectFile155 ? await readT3ProjectFile(project.environmentId, project.workspaceRoot)
161 ? await readT3ProjectFileDefaultThreadEnvMode(156 : null;
162 project.environmentId,157 return resolveProjectSettings(
163 project.workspaceRoot,158 targetServerSettings,
164 )159 project?.id ?? null,
165 : null,160 project,
166 globalDefault: projectSettings.settings.defaultThreadEnvMode,161 projectFile,
167 });162 ).settings.defaultThreadEnvMode;
168 };163 };
169 const logicalProjectKey = project164 const logicalProjectKey = project
170 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)165 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
apps/web/src/lib/t3ProjectFileDefaults.ts+9-9
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { T3_PROJECT_FILE_NAME, type EnvironmentId, type ThreadEnvMode } from "@t3tools/contracts";1import { T3_PROJECT_FILE_NAME, type EnvironmentId, type T3ProjectFile } from "@t3tools/contracts";
2import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";2import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
3import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";3import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
44
...@@ -9,19 +9,19 @@ import {...@@ -9,19 +9,19 @@ import {
9import { appAtomRegistry } from "~/rpc/atomRegistry";9import { appAtomRegistry } from "~/rpc/atomRegistry";
1010
11/**11/**
12 * Read `defaultThreadEnvMode` from the project's checked-in `t3.json`.12 * Read and decode the project's checked-in `t3.json`.
13 *13 *
14 * Imperative counterpart to `useT3ProjectFileScripts` for the new-thread14 * Imperative counterpart to `useT3ProjectFileState` for the new-thread path,
15 * path, which resolves defaults at call time rather than render time. The15 * which resolves defaults at call time rather than render time. The file
16 * file query atom caches per (environment, cwd), so repeat calls don't16 * query atom caches per (environment, cwd), so repeat calls don't re-fetch.
17 * re-fetch. Optimistic in-app writes overlay the query result, matching what17 * Optimistic in-app writes overlay the query result, matching what
18 * `useProjectFileQuery` renders. Missing, truncated, or invalid files18 * `useProjectFileQuery` renders. Missing, truncated, or invalid files
19 * resolve to null.19 * resolve to null.
20 */20 */
21export async function readT3ProjectFileDefaultThreadEnvMode(21export async function readT3ProjectFile(
22 environmentId: EnvironmentId,22 environmentId: EnvironmentId,
23 workspaceRoot: string,23 workspaceRoot: string,
24): Promise<ThreadEnvMode | null> {24): Promise<T3ProjectFile | null> {
25 const result = await executeAtomQuery(25 const result = await executeAtomQuery(
26 appAtomRegistry,26 appAtomRegistry,
27 getProjectFileQueryAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME),27 getProjectFileQueryAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME),
...@@ -34,5 +34,5 @@ export async function readT3ProjectFileDefaultThreadEnvMode(...@@ -34,5 +34,5 @@ export async function readT3ProjectFileDefaultThreadEnvMode(
34 result._tag === "Success" ? result.value : null,34 result._tag === "Success" ? result.value : null,
35 );35 );
36 if (data === null || data.truncated) return null;36 if (data === null || data.truncated) return null;
37 return parseT3ProjectFile(data.contents)?.defaultThreadEnvMode ?? null;37 return parseT3ProjectFile(data.contents);
38}38}
docs/user/project-settings.md+3-1
...@@ -49,7 +49,9 @@ checkouts and removal. Actions belong to a project: editing them creates the pro...@@ -49,7 +49,9 @@ checkouts and removal. Actions belong to a project: editing them creates the pro
49on each selected environment, and reset returns to the environment's shared list. A project's49on each selected environment, and reset returns to the environment's shared list. A project's
50`t3.json` actions can be imported there.50`t3.json` actions can be imported there.
5151
52For workspace mode, a project's `t3.json` preference applies when the project has no override.52Settings a repository can also declare in `t3.json`, such as the workspace for new threads,
53resolve in one order: a project override, then the environment setting, then `t3.json`, then the
54built-in default. Leave a setting on **Inherit** to let the next tier decide.
53Browser access changes apply when an agent session next starts.55Browser access changes apply when an agent session next starts.
5456
55New worktrees initialize git submodules recursively. If that step is slow because the repository57New worktrees initialize git submodules recursively. If that step is slow because the repository
packages/contracts/src/baseSchemas.ts+25
...@@ -82,6 +82,31 @@ export const ForwardCompatibleNullable = <Value extends Schema.Top>(value: Value...@@ -82,6 +82,31 @@ export const ForwardCompatibleNullable = <Value extends Schema.Top>(value: Value
82 );82 );
83};83};
8484
85/**
86 * A nullable setting whose null is "unset" and never crosses the wire: it
87 * decodes from a missing or unknown key and encodes back to a missing key.
88 * For a field that older clients decode as a required literal, so a null
89 * on the wire would fail their whole settings snapshot.
90 */
91export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => {
92 const decodeValue = Schema.decodeUnknownOption(value as never);
93 return Schema.optionalKey(Schema.Unknown).pipe(
94 Schema.decodeTo(
95 Schema.NullOr(value),
96 SchemaTransformation.transformOptional<Value["Encoded"] | null, unknown>({
97 decode: (raw) =>
98 Option.some(
99 Option.isSome(raw) && Option.isSome(decodeValue(raw.value))
100 ? (raw.value as Value["Encoded"])
101 : null,
102 ),
103 encode: (raw) =>
104 Option.isSome(raw) && raw.value !== null ? Option.some(raw.value) : Option.none(),
105 }),
106 ),
107 );
108};
109
85export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => {110export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => {
86 const decodeElement = Schema.decodeUnknownOption(element as never);111 const decodeElement = Schema.decodeUnknownOption(element as never);
87 return Schema.Array(Schema.Unknown).pipe(112 return Schema.Array(Schema.Unknown).pipe(
packages/contracts/src/settings.test.ts+21
...@@ -805,6 +805,27 @@ describe("provider enabled defaults", () => {...@@ -805,6 +805,27 @@ describe("provider enabled defaults", () => {
805});805});
806806
807describe("ServerSettings worktree defaults", () => {807describe("ServerSettings worktree defaults", () => {
808 it("defaults the thread env mode to inherit and keeps stored values", () => {
809 expect(decodeServerSettings({}).defaultThreadEnvMode).toBeNull();
810 expect(decodeServerSettings({ defaultThreadEnvMode: "worktree" }).defaultThreadEnvMode).toBe(
811 "worktree",
812 );
813 expect(
814 decodeServerSettings({ defaultThreadEnvMode: "remote" }).defaultThreadEnvMode,
815 ).toBeNull();
816 expect(
817 decodeServerSettingsPatch({ defaultThreadEnvMode: null }).defaultThreadEnvMode,
818 ).toBeNull();
819 });
820
821 it("keeps an inherited thread env mode off the wire for older clients", () => {
822 const encode = Schema.encodeSync(ServerSettings);
823 expect("defaultThreadEnvMode" in encode(decodeServerSettings({}))).toBe(false);
824 expect(
825 encode(decodeServerSettings({ defaultThreadEnvMode: "worktree" })).defaultThreadEnvMode,
826 ).toBe("worktree");
827 });
828
808 it("defaults start-from-origin on for legacy configs", () => {829 it("defaults start-from-origin on for legacy configs", () => {
809 expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true);830 expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true);
810 });831 });
packages/contracts/src/settings.ts+30-4
...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
5import * as SchemaTransformation from "effect/SchemaTransformation";5import * as SchemaTransformation from "effect/SchemaTransformation";
6import {6import {
7 ForwardCompatibleNullable,7 ForwardCompatibleNullable,
8 OmittedWhenNull,
8 ProjectId,9 ProjectId,
9 TrimmedNonEmptyString,10 TrimmedNonEmptyString,
10 TrimmedString,11 TrimmedString,
...@@ -1041,6 +1042,26 @@ export const ProjectSettingsOverrides = Schema.Struct({...@@ -1041,6 +1042,26 @@ export const ProjectSettingsOverrides = Schema.Struct({
1041} satisfies Record<ProjectScopedServerSettingKey, unknown>);1042} satisfies Record<ProjectScopedServerSettingKey, unknown>);
1042export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;1043export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;
10431044
1045/**
1046 * Whether `null` is a stored override value for this key rather than "unset".
1047 * Clients writing a project override treat null for every other key as a
1048 * request to remove the override, so a picker's "Inherit" item and the row's
1049 * reset do the same thing.
1050 */
1051export function isNullableProjectSettingsOverride(key: ProjectScopedServerSettingKey): boolean {
1052 return NULLABLE_PROJECT_SETTINGS_OVERRIDES.has(key);
1053}
1054const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet<ProjectScopedServerSettingKey> = new Set<
1055 {
1056 [K in ProjectScopedServerSettingKey]: null extends ProjectSettingsOverrides[K] ? K : never;
1057 }[ProjectScopedServerSettingKey]
1058>([
1059 "defaultModelSelection",
1060 "sourceControlWriterModelSelection",
1061 "pullRequestMergeMethod",
1062 "sidebarAutoSettleAfterDays",
1063]);
1064
1044export const StorageCleanupSettings = Schema.Struct({1065export const StorageCleanupSettings = Schema.Struct({
1045 worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))),1066 worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
1046 worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),1067 worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
...@@ -1176,9 +1197,14 @@ export const ServerSettings = Schema.Struct({...@@ -1176,9 +1197,14 @@ export const ServerSettings = Schema.Struct({
1176 environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe(1197 environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe(
1177 Schema.withDecodingDefault(Effect.succeed(null)),1198 Schema.withDecodingDefault(Effect.succeed(null)),
1178 ),1199 ),
1179 defaultThreadEnvMode: ThreadEnvMode.pipe(1200 /**
1180 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),1201 * Null means inherit: the repository's t3.json, then "local". The old
1181 ),1202 * default "local" was never persisted (defaults are stripped on write), so
1203 * it now decodes as inherit, which resolves the same way because the old
1204 * chain also let t3.json outrank the environment. Null stays off the wire
1205 * so older clients, which require a literal here, keep decoding.
1206 */
1207 defaultThreadEnvMode: OmittedWhenNull(ThreadEnvMode),
1182 newWorktreesStartFromOrigin: Schema.Boolean.pipe(1208 newWorktreesStartFromOrigin: Schema.Boolean.pipe(
1183 Schema.withDecodingDefault(Effect.succeed(true)),1209 Schema.withDecodingDefault(Effect.succeed(true)),
1184 ),1210 ),
...@@ -1471,7 +1497,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1471,7 +1497,7 @@ export const ServerSettingsPatch = Schema.Struct({
1471 providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis),1497 providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis),
1472 backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile),1498 backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile),
1473 environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)),1499 environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)),
1474 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),1500 defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)),
1475 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),1501 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
1476 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),1502 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
1477 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),1503 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
packages/contracts/src/t3ProjectFile.ts+35
...@@ -3,6 +3,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation";...@@ -3,6 +3,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation";
33
4import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts";4import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts";
5import { ProjectScriptIcon } from "./orchestration.ts";5import { ProjectScriptIcon } from "./orchestration.ts";
6import type { ProjectScopedServerSettingKey, ServerSettings } from "./settings.ts";
67
7/** File name of the checked-in T3 project file, resolved at the workspace root. */8/** File name of the checked-in T3 project file, resolved at the workspace root. */
8export const T3_PROJECT_FILE_NAME = "t3.json";9export const T3_PROJECT_FILE_NAME = "t3.json";
...@@ -105,3 +106,37 @@ export const T3ProjectFile = Schema.Struct({...@@ -105,3 +106,37 @@ export const T3ProjectFile = Schema.Struct({
105 "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.",106 "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.",
106});107});
107export type T3ProjectFile = typeof T3ProjectFile.Type;108export type T3ProjectFile = typeof T3ProjectFile.Type;
109
110/**
111 * Settings a repository can also declare in t3.json. A key here must be
112 * nullable on `ServerSettings` (null means inherit) so both the project
113 * override and the environment value can defer to the file; `field` names
114 * the t3.json field carrying the same value and `builtIn` is what applies
115 * when every tier is unset. `resolveProjectSettings` walks project override,
116 * environment value, file, built-in, so listing a key here is the whole
117 * change for a new file-backed setting.
118 */
119export const PROJECT_FILE_BACKED_SETTINGS = {
120 defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" },
121} as const satisfies {
122 readonly [K in ProjectScopedServerSettingKey]?: {
123 readonly field: {
124 readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends
125 | Exclude<ServerSettings[K], null>
126 | undefined
127 ? F
128 : never;
129 }[keyof T3ProjectFile];
130 readonly builtIn: Exclude<ServerSettings[K], null>;
131 };
132};
133export type ProjectFileBackedSettingKey = keyof typeof PROJECT_FILE_BACKED_SETTINGS;
134
135/**
136 * `ServerSettings` with every file-backed key resolved to a concrete value.
137 * What `resolveProjectSettings(...).settings` produces once a t3.json (or
138 * its absence) has been accounted for.
139 */
140export type ResolvedServerSettings = Omit<ServerSettings, ProjectFileBackedSettingKey> & {
141 readonly [K in ProjectFileBackedSettingKey]: Exclude<ServerSettings[K], null>;
142};
packages/shared/package.json-4
...@@ -127,10 +127,6 @@...@@ -127,10 +127,6 @@
127 "types": "./src/projectSettings.ts",127 "types": "./src/projectSettings.ts",
128 "import": "./src/projectSettings.ts"128 "import": "./src/projectSettings.ts"
129 },129 },
130 "./threadEnvMode": {
131 "types": "./src/threadEnvMode.ts",
132 "import": "./src/threadEnvMode.ts"
133 },
134 "./t3ProjectFile": {130 "./t3ProjectFile": {
135 "types": "./src/t3ProjectFile.ts",131 "types": "./src/t3ProjectFile.ts",
136 "import": "./src/t3ProjectFile.ts"132 "import": "./src/t3ProjectFile.ts"
packages/shared/src/projectSettings.test.ts+76
...@@ -30,6 +30,24 @@ describe("resolveProjectSettings", () => {...@@ -30,6 +30,24 @@ describe("resolveProjectSettings", () => {
30 );30 );
31 });31 });
3232
33 it("ignores an override left undefined by a forward-compatible decode", () => {
34 const resolved = resolveProjectSettings(
35 {
36 ...DEFAULT_SERVER_SETTINGS,
37 defaultRuntimeMode: "full-access",
38 projectSettingsOverrides: { [projectId]: { defaultRuntimeMode: undefined } as never },
39 },
40 projectId,
41 );
42 expect(resolved.settings.defaultRuntimeMode).toBe("full-access");
43 expect(resolved.sources.defaultRuntimeMode).toBe("environment");
44 expect(
45 hasProjectSettingsOverrides({
46 projectSettingsOverrides: { [projectId]: { defaultRuntimeMode: undefined } as never },
47 }),
48 ).toBe(false);
49 });
50
33 it("treats a null project like an absent one before the shell snapshot arrives", () => {51 it("treats a null project like an absent one before the shell snapshot arrives", () => {
34 // The mobile new-task flow resolves settings while its selected project is52 // The mobile new-task flow resolves settings while its selected project is
35 // still null; reading the aggregate's legacy fields off null crashed launch.53 // still null; reading the aggregate's legacy fields off null crashed launch.
...@@ -118,6 +136,64 @@ describe("resolveProjectSettings", () => {...@@ -118,6 +136,64 @@ describe("resolveProjectSettings", () => {
118 });136 });
119});137});
120138
139describe("resolveProjectSettings with a t3.json", () => {
140 it("walks project override, environment value, file, then built-in for file-backed keys", () => {
141 const file = { defaultThreadEnvMode: "worktree" as const };
142 const fromOverride = resolveProjectSettings(
143 {
144 ...DEFAULT_SERVER_SETTINGS,
145 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "local" } },
146 },
147 projectId,
148 null,
149 file,
150 );
151 expect(fromOverride.settings.defaultThreadEnvMode).toBe("local");
152 expect(fromOverride.sources.defaultThreadEnvMode).toBe("project");
153
154 const fromEnvironment = resolveProjectSettings(
155 { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "local" },
156 projectId,
157 null,
158 file,
159 );
160 expect(fromEnvironment.settings.defaultThreadEnvMode).toBe("local");
161 expect(fromEnvironment.sources.defaultThreadEnvMode).toBe("environment");
162
163 const fromFile = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, file);
164 expect(fromFile.settings.defaultThreadEnvMode).toBe("worktree");
165 expect(fromFile.sources.defaultThreadEnvMode).toBe("t3.json");
166
167 const builtIn = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, null);
168 expect(builtIn.settings.defaultThreadEnvMode).toBe("local");
169 expect(builtIn.sources.defaultThreadEnvMode).toBe("environment");
170 // A stored null override defers like an unset one and is not reported
171 // as the project's value.
172 const nullOverride = resolveProjectSettings(
173 {
174 ...DEFAULT_SERVER_SETTINGS,
175 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: null } as never },
176 },
177 projectId,
178 null,
179 file,
180 );
181 expect(nullOverride.settings.defaultThreadEnvMode).toBe("worktree");
182 expect(nullOverride.sources.defaultThreadEnvMode).toBe("t3.json");
183 // A file that does not mention the key leaves the source alone too.
184 expect(
185 resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, {}).sources
186 .defaultThreadEnvMode,
187 ).toBe("environment");
188 });
189
190 it("leaves settings untouched when no file is passed", () => {
191 expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId).settings).toBe(
192 DEFAULT_SERVER_SETTINGS,
193 );
194 });
195});
196
121describe("projectSettingsOverrides patches", () => {197describe("projectSettingsOverrides patches", () => {
122 it("replaces a project's entry, removes it with null, and drops empty entries", () => {198 it("replaces a project's entry, removes it with null, and drops empty entries", () => {
123 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {199 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
packages/shared/src/projectSettings.ts+68-4
...@@ -1,24 +1,34 @@...@@ -1,24 +1,34 @@
1import {1import {
2 type ModelSelection,2 type ModelSelection,
3 PROJECT_FILE_BACKED_SETTINGS,
3 PROJECT_SCOPED_SERVER_SETTING_KEYS,4 PROJECT_SCOPED_SERVER_SETTING_KEYS,
4 type ProjectId,5 type ProjectId,
5 type ProjectScopedServerSettingKey,6 type ProjectScopedServerSettingKey,
6 type ProjectSettingsOverrides,7 type ProjectSettingsOverrides,
8 type ResolvedServerSettings,
7 type ServerSettings,9 type ServerSettings,
10 type T3ProjectFile,
8 type ThreadEnvMode,11 type ThreadEnvMode,
9 type WorktreeCleanupRules,12 type WorktreeCleanupRules,
10} from "@t3tools/contracts";13} from "@t3tools/contracts";
11import { isModelSelectionProviderEnabled } from "./serverSettings.ts";14import { isModelSelectionProviderEnabled } from "./serverSettings.ts";
1215
13export type ProjectSettingSource = "environment" | "project";16/**
17 * Where a project-scoped value came from. The order is the priority order:
18 * a project override, then the environment value, then the repository's
19 * t3.json for keys in `PROJECT_FILE_BACKED_SETTINGS`, then the built-in
20 * default (reported as "environment", since that is what the environment
21 * value is when nothing set it).
22 */
23export type ProjectSettingSource = "environment" | "project" | "t3.json";
1424
15export type ProjectSettingSources = Readonly<25export type ProjectSettingSources = Readonly<
16 Record<ProjectScopedServerSettingKey, ProjectSettingSource>26 Record<ProjectScopedServerSettingKey, ProjectSettingSource>
17>;27>;
1828
19export interface ResolvedProjectSettings {29export interface ResolvedProjectSettings<Settings extends ServerSettings = ServerSettings> {
20 /** Environment settings with the project's overrides applied. */30 /** Environment settings with the project's overrides applied. */
21 readonly settings: ServerSettings;31 readonly settings: Settings;
22 /** Where each scopable key's effective value came from. */32 /** Where each scopable key's effective value came from. */
23 readonly sources: ProjectSettingSources;33 readonly sources: ProjectSettingSources;
24 /** The project's raw override entry; `{}` when it has none. */34 /** The project's raw override entry; `{}` when it has none. */
...@@ -36,7 +46,9 @@ export function hasProjectSettingsOverrides(...@@ -36,7 +46,9 @@ export function hasProjectSettingsOverrides(
36 settings: Pick<ServerSettings, "projectSettingsOverrides">,46 settings: Pick<ServerSettings, "projectSettingsOverrides">,
37): boolean {47): boolean {
38 for (const entry of Object.values(settings.projectSettingsOverrides)) {48 for (const entry of Object.values(settings.projectSettingsOverrides)) {
39 if (Object.keys(entry).length > 0) return true;49 // A forward-compatible decode can leave an unknown value as a present
50 // undefined; that is not an override.
51 if (Object.values(entry).some((value) => value !== undefined)) return true;
40 }52 }
41 return false;53 return false;
42}54}
...@@ -63,6 +75,55 @@ export function resolveProjectSettings(...@@ -63,6 +75,55 @@ export function resolveProjectSettings(
63 // Nullable, not just optional: the mobile new-task flow passes its selected75 // Nullable, not just optional: the mobile new-task flow passes its selected
64 // project straight through, and that is null until the shell snapshot lands.76 // project straight through, and that is null until the shell snapshot lands.
65 project?: LegacyProjectSettingsFields | null,77 project?: LegacyProjectSettingsFields | null,
78): ResolvedProjectSettings;
79/**
80 * With the checkout's decoded t3.json (or null for a missing or invalid
81 * one), every file-backed key resolves to a concrete value: the file fills
82 * keys whose project and environment tiers are both unset, and the built-in
83 * default fills what is left.
84 */
85export function resolveProjectSettings(
86 settings: ServerSettings,
87 projectId: ProjectId | null,
88 project: LegacyProjectSettingsFields | null | undefined,
89 projectFile: T3ProjectFile | null,
90): ResolvedProjectSettings<ResolvedServerSettings>;
91export function resolveProjectSettings(
92 settings: ServerSettings,
93 projectId: ProjectId | null,
94 project?: LegacyProjectSettingsFields | null,
95 projectFile?: T3ProjectFile | null,
96): ResolvedProjectSettings {
97 const resolved = resolveProjectOverrides(settings, projectId, project);
98 return projectFile === undefined ? resolved : applyProjectFile(resolved, projectFile);
99}
100
101function applyProjectFile(
102 resolved: ResolvedProjectSettings,
103 projectFile: T3ProjectFile | null,
104): ResolvedProjectSettings {
105 let effective: Record<string, unknown> | null = null;
106 let sources: Record<ProjectScopedServerSettingKey, ProjectSettingSource> | null = null;
107 for (const [key, { field, builtIn }] of Object.entries(PROJECT_FILE_BACKED_SETTINGS)) {
108 const settingKey = key as ProjectScopedServerSettingKey;
109 if (resolved.settings[settingKey] !== null) continue;
110 const fromFile = projectFile?.[field];
111 effective ??= { ...resolved.settings };
112 sources ??= { ...resolved.sources };
113 effective[settingKey] = fromFile ?? builtIn;
114 // A project override of null defers like an unset one, so the value did
115 // not come from the project either way.
116 sources[settingKey] = fromFile === undefined ? "environment" : "t3.json";
117 }
118 return effective === null || sources === null
119 ? resolved
120 : { ...resolved, settings: effective as ServerSettings, sources };
121}
122
123function resolveProjectOverrides(
124 settings: ServerSettings,
125 projectId: ProjectId | null,
126 project?: LegacyProjectSettingsFields | null,
66): ResolvedProjectSettings {127): ResolvedProjectSettings {
67 const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId];128 const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId];
68 const overrides: ProjectSettingsOverrides =129 const overrides: ProjectSettingsOverrides =
...@@ -87,6 +148,9 @@ export function resolveProjectSettings(...@@ -87,6 +148,9 @@ export function resolveProjectSettings(
87 for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {148 for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
88 if (!Object.hasOwn(overrides, key)) continue;149 if (!Object.hasOwn(overrides, key)) continue;
89 const value = overrides[key];150 const value = overrides[key];
151 // A forward-compatible decode leaves an unknown value as a present
152 // undefined; that is not an override.
153 if (value === undefined) continue;
90 // A model on a disabled provider falls back to the environment, like the154 // A model on a disabled provider falls back to the environment, like the
91 // environment-level guards do for these keys.155 // environment-level guards do for these keys.
92 if (156 if (
packages/shared/src/threadEnvMode.test.ts deleted-65
...@@ -1,65 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isDefaultThreadEnvModeSettled, resolveDefaultThreadEnvMode } from "./threadEnvMode.ts";
4
5describe("resolveDefaultThreadEnvMode", () => {
6 it("prefers the project setting over t3.json over the global default", () => {
7 expect(
8 resolveDefaultThreadEnvMode({
9 projectSetting: "local",
10 projectFile: "worktree",
11 globalDefault: "worktree",
12 }),
13 ).toBe("local");
14 expect(
15 resolveDefaultThreadEnvMode({
16 projectSetting: null,
17 projectFile: "local",
18 globalDefault: "worktree",
19 }),
20 ).toBe("local");
21 expect(
22 resolveDefaultThreadEnvMode({
23 projectSetting: undefined,
24 projectFile: null,
25 globalDefault: "worktree",
26 }),
27 ).toBe("worktree");
28 });
29});
30
31describe("isDefaultThreadEnvModeSettled", () => {
32 it("settles on an explicit pick or project setting even while the file loads", () => {
33 expect(
34 isDefaultThreadEnvModeSettled({
35 explicitMode: "local",
36 projectSetting: null,
37 projectFilePending: true,
38 }),
39 ).toBe(true);
40 expect(
41 isDefaultThreadEnvModeSettled({
42 explicitMode: undefined,
43 projectSetting: "worktree",
44 projectFilePending: true,
45 }),
46 ).toBe(true);
47 });
48
49 it("stays unsettled only while a consulted file read is pending", () => {
50 expect(
51 isDefaultThreadEnvModeSettled({
52 explicitMode: undefined,
53 projectSetting: null,
54 projectFilePending: true,
55 }),
56 ).toBe(false);
57 expect(
58 isDefaultThreadEnvModeSettled({
59 explicitMode: undefined,
60 projectSetting: null,
61 projectFilePending: false,
62 }),
63 ).toBe(true);
64 });
65});
packages/shared/src/threadEnvMode.ts deleted-36
...@@ -1,36 +0,0 @@
1import type { ThreadEnvMode } from "@t3tools/contracts";
2
3/**
4 * Canonical priority order for a project's default thread env mode:
5 * per-project setting > checked-in t3.json > global server setting.
6 *
7 * An explicit composer pick outranks all of these; callers apply it before
8 * consulting the defaults. Web resolves the sources imperatively at draft
9 * creation, mobile reactively — both must route through this function so the
10 * platforms cannot disagree on the order.
11 */
12export function resolveDefaultThreadEnvMode(sources: {
13 readonly projectSetting: ThreadEnvMode | null | undefined;
14 readonly projectFile: ThreadEnvMode | null | undefined;
15 readonly globalDefault: ThreadEnvMode;
16}): ThreadEnvMode {
17 return sources.projectSetting ?? sources.projectFile ?? sources.globalDefault;
18}
19
20/**
21 * True once the resolved default can no longer change: an explicit pick or a
22 * source that outranks t3.json decided, or the file read settled. While
23 * false, nothing may persist the provisional default (for example into a
24 * draft's workspace selection) — it could differ from the final value.
25 */
26export function isDefaultThreadEnvModeSettled(sources: {
27 readonly explicitMode: ThreadEnvMode | undefined;
28 readonly projectSetting: ThreadEnvMode | null | undefined;
29 readonly projectFilePending: boolean;
30}): boolean {
31 return (
32 sources.explicitMode !== undefined ||
33 sources.projectSetting != null ||
34 !sources.projectFilePending
35 );
36}