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
5050};
5151
5252const WORKSPACE_CHOICES: ReadonlyArray<{
53 readonly mode: ThreadEnvMode;
53 readonly mode: ThreadEnvMode | null;
5454 readonly label: string;
5555 readonly description: string;
5656}> = [
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 },
5763 {
5864 mode: "local",
5965 label: "Current checkout",
......@@ -126,6 +132,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
126132 const value = reference.settings[key];
127133 return displayTargets.every((entry) => entry.settings[key] === value) ? value : null;
128134 };
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]);
129139 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
130140 label: "environment settings update",
131141 reportFailure: true,
......@@ -219,17 +229,22 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
219229 <SettingsSection
220230 title="Default workspace"
221231 trailing={
222 pendingWrites === 0 && uniform("defaultThreadEnvMode") === null ? (
232 pendingWrites === 0 && isMixed("defaultThreadEnvMode") ? (
223233 <MixedValuesLabel projectSelected={projectSelected} />
224234 ) : null
225235 }
226236 >
227 {WORKSPACE_CHOICES.map((choice, index) => (
237 {WORKSPACE_CHOICES.filter(
238 (choice) => choice.mode !== null || !projectSelected,
239 ).map((choice, index) => (
228240 <ChoiceRow
229 key={choice.mode}
241 key={choice.mode ?? "inherit"}
230242 label={choice.label}
231243 description={choice.description}
232 selected={uniform("defaultThreadEnvMode") === choice.mode}
244 selected={
245 !isMixed("defaultThreadEnvMode") &&
246 uniform("defaultThreadEnvMode") === choice.mode
247 }
233248 separated={index > 0}
234249 disabled={disabledFor("defaultThreadEnvMode")}
235250 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", () => {
6969 expect(secondSettings.responseStreamingMode).toBe("token");
7070 });
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
72105 it("resets only the selected page's override and rejects environment-wide writes", () => {
73106 const settings: ServerSettings = {
74107 ...DEFAULT_SERVER_SETTINGS,
apps/mobile/src/features/settings/settings-scoped-server.ts+14-1
......@@ -1,4 +1,5 @@
11import {
2 isNullableProjectSettingsOverride,
23 PROJECT_SCOPED_SERVER_SETTING_KEYS,
34 type EnvironmentId,
45 type ProjectId,
......@@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch(
6970 continue;
7071 const current =
7172 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 }
7386 const overrides = writes.get(target.environment.environmentId) ?? {};
7487 overrides[target.projectId] = next;
7588 writes.set(target.environment.environmentId, overrides);
apps/mobile/src/features/threads/new-task-flow-provider.tsx+21-28
......@@ -20,10 +20,6 @@ import {
2020} from "@t3tools/contracts";
2121import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
2222import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
23import {
24 isDefaultThreadEnvModeSettled,
25 resolveDefaultThreadEnvMode,
26} from "@t3tools/shared/threadEnvMode";
2723import * as Arr from "effect/Array";
2824import { pipe } from "effect/Function";
2925
......@@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
431427 : null,
432428 );
433429 const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null;
434 const t3ProjectFileDefaultMode = useMemo(() => {
435 if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null;
436 return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null;
437 }, [t3ProjectFileData]);
438 // Environment settings with the project's overrides applied; the
439 // aggregate's own legacy fields still count until the server folds them.
430 const t3ProjectFile = useMemo(
431 () =>
432 t3ProjectFileData === null || t3ProjectFileData.truncated
433 ? null
434 : parseT3ProjectFile(t3ProjectFileData.contents),
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.
440440 const projectSettings = useMemo(
441441 () =>
442442 resolveProjectSettings(
443443 selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
444444 selectedProject?.id ?? null,
445445 selectedProject,
446 t3ProjectFile,
446447 ),
447 [selectedEnvironmentServerConfig?.settings, selectedProject],
448 [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile],
448449 );
449 const projectThreadEnvMode =
450 projectSettings.sources.defaultThreadEnvMode === "project"
451 ? projectSettings.settings.defaultThreadEnvMode
452 : undefined;
453 const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({
454 projectSetting: projectThreadEnvMode,
455 projectFile: t3ProjectFileDefaultMode,
456 globalDefault: projectSettings.settings.defaultThreadEnvMode,
457 });
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 });
450 const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode;
451 // While the file read is pending and nothing above it decided, the
452 // resolved default is provisional. Nothing may write it into the draft
453 // during that window (the auto-branch effect does), or the frozen interim
454 // value beats the t3.json default once it loads.
455 const defaultWorkspaceModeSettled =
456 selectedProjectDraft.workspaceSelection?.mode !== undefined ||
457 projectSettings.sources.defaultThreadEnvMode !== "environment" ||
458 !t3ProjectFileQuery.isPending;
466459 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
467460 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
468461 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
apps/web/src/components/files/projectFilesQueryState.ts+6-1
......@@ -20,7 +20,12 @@ const EMPTY_PROJECT_FILE_PATH = "";
2020const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make(
2121 AsyncResult.initial<ProjectReadFileResult, never>(false),
2222).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) {
2429 return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });
2530}
2631
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+14-31
......@@ -1,13 +1,12 @@
11import {
22 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,
43 type ModelSelection,
54 type ProviderInstanceId,
65} from "@t3tools/contracts";
76import { createModelSelection } from "@t3tools/shared/model";
7import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
88import { useNavigate } from "@tanstack/react-router";
99
10import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
1110import { getCustomModelOptionsByInstance } from "../../modelSelection";
1211import {
1312 applyProviderInstanceSettings,
......@@ -75,25 +74,16 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
7574 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
7675 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
7776 const modelSource = useScopedSettingSource(["defaultModelSelection"]);
78 const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]);
7977 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
8078 const unavailable = connectedEnvironments.length === 0;
81
82 // A checkout's t3.json wins over the environment default when the project
83 // has no override of its own; show which one "inherit" resolves to.
84 const checkout = scope.kind === "checkout" ? scope.checkout : null;
85 // The query is disabled without a checkout, so any id satisfies the hook.
86 const t3File = useT3ProjectFileState(
87 checkout?.environmentId ?? EnvironmentId.make("none"),
88 category === "general" && checkout ? checkout.workspaceRoot : 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;
79 // File-backed keys show their effective value; the target already carries
80 // the checkout's t3.json, and a null file here only fills the built-in.
81 // The reset arrow beside the title clears the tier (SettingsRow handles a
82 // project override, the environment value is cleared here), so the picker
83 // has no "inherit" item.
84 const effective = target
85 ? resolveProjectSettings(target.settings, null, null, null).settings
86 : null;
9787
9888 function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {
9989 const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
......@@ -292,27 +282,20 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
292282 title="Workspace"
293283 description={
294284 isProjectScope
295 ? "Where new threads in this project start. A t3.json preference applies when the project has no override."
296 : "Where new threads start, unless overridden by the project or t3.json."
297 }
298 status={
299 inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined
285 ? "Where new threads in this project start."
286 : "Where new threads start. Projects and their t3.json can override it."
300287 }
301288 resetAction={
302 settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? (
289 !isProjectScope && settings.defaultThreadEnvMode !== null ? (
303290 <SettingResetButton
304291 label="default workspace"
305 onClick={() =>
306 updateSettings({
307 defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode,
308 })
309 }
292 onClick={() => updateSettings({ defaultThreadEnvMode: null })}
310293 />
311294 ) : null
312295 }
313296 control={
314297 <Select
315 value={mixedWorkspace ? null : settings.defaultThreadEnvMode}
298 value={mixedWorkspace ? null : (effective?.defaultThreadEnvMode ?? null)}
316299 onValueChange={(value) => {
317300 if (value === "local" || value === "worktree")
318301 updateSettings({ defaultThreadEnvMode: value });
apps/web/src/components/settings/SettingInheritance.test.ts+37
......@@ -54,4 +54,41 @@ describe("settingInheritanceLayers", () => {
5454 ["Off", false],
5555 ]);
5656 });
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 });
5794});
apps/web/src/components/settings/SettingInheritance.tsx+36-11
......@@ -1,5 +1,7 @@
11import {
22 DEFAULT_SERVER_SETTINGS,
3 PROJECT_FILE_BACKED_SETTINGS,
4 type ProjectFileBackedSettingKey,
35 resolveEnvironmentMachineKind,
46 type ServerSettings,
57} from "@t3tools/contracts";
......@@ -18,7 +20,7 @@ import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSetting
1820import { isProjectScopedSettingKey } from "./scopedSettings";
1921
2022interface InheritanceLayer {
21 readonly key: "project" | "environment" | "built-in";
23 readonly key: "project" | "environment" | "t3.json" | "built-in";
2224 readonly label: string;
2325 readonly value: string;
2426 readonly effective: boolean;
......@@ -42,7 +44,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
4244 ? "Automatic"
4345 : key === "sourceControlWriterModelSelection"
4446 ? "Text generation model"
45 : "Not set";
47 : key === "defaultThreadEnvMode"
48 ? "Inherit"
49 : "Not set";
4650 }
4751 if (typeof value === "boolean") return value ? "On" : "Off";
4852 if (typeof value === "number") {
......@@ -74,44 +78,65 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
7478/**
7579 * The layers a setting resolves through for one target, top-down: the
7680 * 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`.
7883 */
7984export function settingInheritanceLayers(
8085 target: ScopedSettingsTarget,
8186 environmentSettings: ServerSettings,
8287 key: keyof ServerSettings,
8388): readonly InheritanceLayer[] {
84 const builtIn = DEFAULT_SERVER_SETTINGS[key];
8589 const environmentValue = environmentSettings[key];
86 const projectSource = isProjectScopedSettingKey(key) ? target.sources[key] : "environment";
87 const environmentSet = !Equal.equals(environmentValue, builtIn);
90 const source = isProjectScopedSettingKey(key) ? target.sources[key] : "environment";
91 const environmentSet = !Equal.equals(environmentValue, DEFAULT_SERVER_SETTINGS[key]);
92 const fileBacked = isProjectFileBackedSettingKey(key);
8893 const layers: InheritanceLayer[] = [];
8994 if (target.projectId !== null && isProjectScopedSettingKey(key)) {
9095 layers.push({
9196 key: "project",
9297 label: "Project",
93 value: projectSource === "project" ? formatValue(key, target.settings[key]) : "Inherits",
94 effective: projectSource === "project",
95 set: projectSource === "project",
98 value: source === "project" ? formatValue(key, target.settings[key]) : "Inherits",
99 effective: source === "project",
100 set: source === "project",
96101 });
97102 }
98103 layers.push({
99104 key: "environment",
100105 label: target.label,
101106 value: environmentSet ? formatValue(key, environmentValue) : "Inherits",
102 effective: projectSource !== "project" && environmentSet,
107 effective: source === "environment" && environmentSet,
103108 set: environmentSet,
104109 });
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];
105124 layers.push({
106125 key: "built-in",
107126 label: "Default",
108127 value: formatValue(key, builtIn),
109 effective: projectSource !== "project" && !environmentSet,
128 effective: source === "environment" && !environmentSet,
110129 set: true,
111130 });
112131 return layers;
113132}
114133
134function isProjectFileBackedSettingKey(
135 key: keyof ServerSettings,
136): key is ProjectFileBackedSettingKey {
137 return Object.hasOwn(PROJECT_FILE_BACKED_SETTINGS, key);
138}
139
115140export type SettingInheritanceState =
116141 | "default"
117142 | "environment"
apps/web/src/components/settings/SettingsScopeContext.tsx+59-3
......@@ -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";
16import { createContext, type ReactNode, useContext, useMemo } from "react";
27
38import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
9import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFilesQueryState";
410import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
511import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";
612import { 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
856function useResolvedSettingsScope(search: SettingsScopeSearch) {
957 const groups = useSettingsProjectGroups();
1058 const { environments: availableEnvironments } = useEnvironments();
1159 const primaryEnvironmentId = usePrimaryEnvironmentId();
60 const scope = useMemo(
61 () => resolveSettingsScope(search, groups, availableEnvironments),
62 [availableEnvironments, groups, search],
63 );
64 const projectFiles = useMemberProjectFiles(scope);
1265 return useMemo(() => {
13 const scope = resolveSettingsScope(search, groups, availableEnvironments);
1466 const selected = selectScopedSettingsEnvironments(
1567 scope,
1668 availableEnvironments,
1769 primaryEnvironmentId,
1870 );
19 const targets = resolveScopedSettingsTargets(scope, selected.connectedEnvironments);
71 const targets = resolveScopedSettingsTargets(
72 scope,
73 selected.connectedEnvironments,
74 projectFiles,
75 );
2076 // The representative target supplies display values; project scopes
2177 // prefer the member on the primary environment, like environments do.
2278 const target =
......@@ -26,7 +82,7 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) {
2682 targets[0] ??
2783 null;
2884 return { scope, groups, ...selected, targets, target };
29 }, [availableEnvironments, groups, primaryEnvironmentId, search]);
85 }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope]);
3086}
3187
3288const SettingsScopeContext = createContext<
apps/web/src/components/settings/scopedSettings.test.ts+47
......@@ -510,6 +510,53 @@ describe("project overrides at environment scope", () => {
510510 });
511511});
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
513560describe("partial object patches at project scope", () => {
514561 it("completes a writing style field patch from the target's effective value", () => {
515562 const environmentId = EnvironmentId.make("laptop");
apps/web/src/components/settings/scopedSettings.ts+26-2
......@@ -2,11 +2,13 @@ import {
22 ClientSettingsSchema,
33 type ClientSettingsPatch,
44 type EnvironmentId,
5 isNullableProjectSettingsOverride,
56 PROJECT_SCOPED_SERVER_SETTING_KEYS,
67 type ProjectId,
78 type ProjectScopedServerSettingKey,
89 type ProjectSettingsOverrides,
910 ServerSettings,
11 type T3ProjectFile,
1012 type ServerSettingsPatch,
1113} from "@t3tools/contracts";
1214import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
......@@ -85,6 +87,9 @@ export interface ScopedSettingsTarget {
8587export function resolveScopedSettingsTargets(
8688 scope: ResolvedSettingsScope,
8789 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>,
8893): readonly ScopedSettingsTarget[] {
8994 const byId = new Map(
9095 connectedEnvironments.map((environment) => [environment.environmentId, environment]),
......@@ -93,7 +98,11 @@ export function resolveScopedSettingsTargets(
9398 return scope.members.flatMap((member) => {
9499 const environment = byId.get(member.environmentId);
95100 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);
97106 return [
98107 {
99108 environmentId: member.environmentId,
......@@ -143,7 +152,13 @@ export function scopedSettingsSource(
143152 const scoped = keys.filter(isProjectScopedSettingKey);
144153 if (scoped.length === 0 || targets.length === 0) return "environment";
145154 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";
147162}
148163
149164interface ScopedServerWrite {
......@@ -234,6 +249,15 @@ export function planScopedSettingsPatch(
234249 };
235250 continue;
236251 }
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 }
237261 const base = effective[key as keyof ServerSettings];
238262 next[key] =
239263 isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
apps/web/src/components/settings/settingsLayout.tsx+7-5
......@@ -407,11 +407,13 @@ export function SettingsRow({
407407 ? { state: "mixed", summary: "Mixed across selected environments" }
408408 : source === "project"
409409 ? { state: "overridden", summary: "Overridden for this project" }
410 : source === "environment" && scopedKeys.length > 0
411 ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` }
412 : customized
413 ? { state: "environment", summary: "Set on the environment" }
414 : { state: "default", summary: "Built-in default" };
410 : source === "t3.json"
411 ? { state: "inherited", summary: "Inherited from the repository's t3.json" }
412 : source === "environment" && scopedKeys.length > 0
413 ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` }
414 : customized
415 ? { state: "environment", summary: "Set on the environment" }
416 : { state: "default", summary: "Built-in default" };
415417 const renderedInheritance =
416418 context && serverScoped && settingKeys.length > 0 ? (
417419 <SettingInheritance
apps/web/src/hooks/useHandleNewThread.test.ts+16-9
......@@ -100,18 +100,25 @@ vi.mock("@t3tools/contracts", () => ({
100100vi.mock("@t3tools/shared/projectSettings", () => ({
101101 // Environment settings pass through; the tests set project fields on the
102102 // project record, which the hook still honors until the server folds them.
103 resolveProjectSettings: (settings: Record<string, unknown>) => ({
104 settings,
103 // With a file argument the env mode resolves like the real chain.
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 },
105118 sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },
106119 overrides: {},
107120 }),
108121}));
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}));
115122vi.mock("@tanstack/react-router", () => ({
116123 useParams: () => null,
117124 useRouter: () => testState.router,
......@@ -137,7 +144,7 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({
137144 resolveNewThreadModelSelectionOverride: () => null,
138145}));
139146vi.mock("../lib/t3ProjectFileDefaults", () => ({
140 readT3ProjectFileDefaultThreadEnvMode: () => testState.projectFileRead,
147 readT3ProjectFile: () => testState.projectFileRead,
141148}));
142149vi.mock("../lib/utils", () => ({
143150 newDraftId: () => "draft-delayed",
apps/web/src/hooks/useHandleNewThread.ts+12-17
......@@ -23,14 +23,13 @@ import {
2323 selectProjectGroupingSettings,
2424} from "../logicalProject";
2525import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
26import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
2726import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
2827import {
2928 hasExplicitComposerModelSelection,
3029 resolveNewDraftStartFromOrigin,
3130 resolveNewThreadModelSelectionOverride,
3231} from "../lib/chatThreadActions";
33import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
32import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults";
3433import { environmentServerConfigsAtom } from "../state/server";
3534import { resolveThreadRouteTarget } from "../threadRoutes";
3635import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
......@@ -138,10 +137,6 @@ export function useNewThreadHandler() {
138137 );
139138 const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection;
140139 const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode;
141 const projectThreadEnvMode =
142 projectSettings.sources.defaultThreadEnvMode === "project"
143 ? projectSettings.settings.defaultThreadEnvMode
144 : undefined;
145140 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
146141 resolveNewThreadModelSelectionOverride({
147142 projectDefaultSelection: projectDefaultModelSelection ?? null,
......@@ -154,17 +149,17 @@ export function useNewThreadHandler() {
154149 // skipped entirely when a higher-priority source decides, and its
155150 // query atom caches per project after the first call.
156151 const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {
157 const consultProjectFile = project !== undefined && projectThreadEnvMode == null;
158 return resolveDefaultThreadEnvMode({
159 projectSetting: projectThreadEnvMode,
160 projectFile: consultProjectFile
161 ? await readT3ProjectFileDefaultThreadEnvMode(
162 project.environmentId,
163 project.workspaceRoot,
164 )
165 : null,
166 globalDefault: projectSettings.settings.defaultThreadEnvMode,
167 });
152 const consultProjectFile =
153 project !== undefined && projectSettings.settings.defaultThreadEnvMode === null;
154 const projectFile = consultProjectFile
155 ? await readT3ProjectFile(project.environmentId, project.workspaceRoot)
156 : null;
157 return resolveProjectSettings(
158 targetServerSettings,
159 project?.id ?? null,
160 project,
161 projectFile,
162 ).settings.defaultThreadEnvMode;
168163 };
169164 const logicalProjectKey = project
170165 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
apps/web/src/lib/t3ProjectFileDefaults.ts+9-9
......@@ -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";
22import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
33import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
44
......@@ -9,19 +9,19 @@ import {
99import { appAtomRegistry } from "~/rpc/atomRegistry";
1010
1111/**
12 * Read `defaultThreadEnvMode` from the project's checked-in `t3.json`.
12 * Read and decode the project's checked-in `t3.json`.
1313 *
14 * Imperative counterpart to `useT3ProjectFileScripts` for the new-thread
15 * path, which resolves defaults at call time rather than render time. The
16 * file query atom caches per (environment, cwd), so repeat calls don't
17 * re-fetch. Optimistic in-app writes overlay the query result, matching what
14 * Imperative counterpart to `useT3ProjectFileState` for the new-thread path,
15 * which resolves defaults at call time rather than render time. The file
16 * query atom caches per (environment, cwd), so repeat calls don't re-fetch.
17 * Optimistic in-app writes overlay the query result, matching what
1818 * `useProjectFileQuery` renders. Missing, truncated, or invalid files
1919 * resolve to null.
2020 */
21export async function readT3ProjectFileDefaultThreadEnvMode(
21export async function readT3ProjectFile(
2222 environmentId: EnvironmentId,
2323 workspaceRoot: string,
24): Promise<ThreadEnvMode | null> {
24): Promise<T3ProjectFile | null> {
2525 const result = await executeAtomQuery(
2626 appAtomRegistry,
2727 getProjectFileQueryAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME),
......@@ -34,5 +34,5 @@ export async function readT3ProjectFileDefaultThreadEnvMode(
3434 result._tag === "Success" ? result.value : null,
3535 );
3636 if (data === null || data.truncated) return null;
37 return parseT3ProjectFile(data.contents)?.defaultThreadEnvMode ?? null;
37 return parseT3ProjectFile(data.contents);
3838}
docs/user/project-settings.md+3-1
......@@ -49,7 +49,9 @@ checkouts and removal. Actions belong to a project: editing them creates the pro
4949on each selected environment, and reset returns to the environment's shared list. A project's
5050`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.
5355Browser access changes apply when an agent session next starts.
5456
5557New 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
8282 );
8383};
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
85110export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => {
86111 const decodeElement = Schema.decodeUnknownOption(element as never);
87112 return Schema.Array(Schema.Unknown).pipe(
packages/contracts/src/settings.test.ts+21
......@@ -805,6 +805,27 @@ describe("provider enabled defaults", () => {
805805});
806806
807807describe("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
808829 it("defaults start-from-origin on for legacy configs", () => {
809830 expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true);
810831 });
packages/contracts/src/settings.ts+30-4
......@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
55import * as SchemaTransformation from "effect/SchemaTransformation";
66import {
77 ForwardCompatibleNullable,
8 OmittedWhenNull,
89 ProjectId,
910 TrimmedNonEmptyString,
1011 TrimmedString,
......@@ -1041,6 +1042,26 @@ export const ProjectSettingsOverrides = Schema.Struct({
10411042} satisfies Record<ProjectScopedServerSettingKey, unknown>);
10421043export 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
10441065export const StorageCleanupSettings = Schema.Struct({
10451066 worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
10461067 worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
......@@ -1176,9 +1197,14 @@ export const ServerSettings = Schema.Struct({
11761197 environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe(
11771198 Schema.withDecodingDefault(Effect.succeed(null)),
11781199 ),
1179 defaultThreadEnvMode: ThreadEnvMode.pipe(
1180 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
1181 ),
1200 /**
1201 * Null means inherit: the repository's t3.json, then "local". The old
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),
11821208 newWorktreesStartFromOrigin: Schema.Boolean.pipe(
11831209 Schema.withDecodingDefault(Effect.succeed(true)),
11841210 ),
......@@ -1471,7 +1497,7 @@ export const ServerSettingsPatch = Schema.Struct({
14711497 providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis),
14721498 backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile),
14731499 environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)),
1474 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
1500 defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)),
14751501 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
14761502 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
14771503 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
packages/contracts/src/t3ProjectFile.ts+35
......@@ -3,6 +3,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation";
33
44import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts";
55import { ProjectScriptIcon } from "./orchestration.ts";
6import type { ProjectScopedServerSettingKey, ServerSettings } from "./settings.ts";
67
78/** File name of the checked-in T3 project file, resolved at the workspace root. */
89export const T3_PROJECT_FILE_NAME = "t3.json";
......@@ -105,3 +106,37 @@ export const T3ProjectFile = Schema.Struct({
105106 "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.",
106107});
107108export 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 @@
127127 "types": "./src/projectSettings.ts",
128128 "import": "./src/projectSettings.ts"
129129 },
130 "./threadEnvMode": {
131 "types": "./src/threadEnvMode.ts",
132 "import": "./src/threadEnvMode.ts"
133 },
134130 "./t3ProjectFile": {
135131 "types": "./src/t3ProjectFile.ts",
136132 "import": "./src/t3ProjectFile.ts"
packages/shared/src/projectSettings.test.ts+76
......@@ -30,6 +30,24 @@ describe("resolveProjectSettings", () => {
3030 );
3131 });
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
3351 it("treats a null project like an absent one before the shell snapshot arrives", () => {
3452 // The mobile new-task flow resolves settings while its selected project is
3553 // still null; reading the aggregate's legacy fields off null crashed launch.
......@@ -118,6 +136,64 @@ describe("resolveProjectSettings", () => {
118136 });
119137});
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
121197describe("projectSettingsOverrides patches", () => {
122198 it("replaces a project's entry, removes it with null, and drops empty entries", () => {
123199 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
packages/shared/src/projectSettings.ts+68-4
......@@ -1,24 +1,34 @@
11import {
22 type ModelSelection,
3 PROJECT_FILE_BACKED_SETTINGS,
34 PROJECT_SCOPED_SERVER_SETTING_KEYS,
45 type ProjectId,
56 type ProjectScopedServerSettingKey,
67 type ProjectSettingsOverrides,
8 type ResolvedServerSettings,
79 type ServerSettings,
10 type T3ProjectFile,
811 type ThreadEnvMode,
912 type WorktreeCleanupRules,
1013} from "@t3tools/contracts";
1114import { 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
1525export type ProjectSettingSources = Readonly<
1626 Record<ProjectScopedServerSettingKey, ProjectSettingSource>
1727>;
1828
19export interface ResolvedProjectSettings {
29export interface ResolvedProjectSettings<Settings extends ServerSettings = ServerSettings> {
2030 /** Environment settings with the project's overrides applied. */
21 readonly settings: ServerSettings;
31 readonly settings: Settings;
2232 /** Where each scopable key's effective value came from. */
2333 readonly sources: ProjectSettingSources;
2434 /** The project's raw override entry; `{}` when it has none. */
......@@ -36,7 +46,9 @@ export function hasProjectSettingsOverrides(
3646 settings: Pick<ServerSettings, "projectSettingsOverrides">,
3747): boolean {
3848 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;
4052 }
4153 return false;
4254}
......@@ -63,6 +75,55 @@ export function resolveProjectSettings(
6375 // Nullable, not just optional: the mobile new-task flow passes its selected
6476 // project straight through, and that is null until the shell snapshot lands.
6577 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,
66127): ResolvedProjectSettings {
67128 const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId];
68129 const overrides: ProjectSettingsOverrides =
......@@ -87,6 +148,9 @@ export function resolveProjectSettings(
87148 for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
88149 if (!Object.hasOwn(overrides, key)) continue;
89150 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;
90154 // A model on a disabled provider falls back to the environment, like the
91155 // environment-level guards do for these keys.
92156 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}