| author | |
| committer | |
| log | 742173a13250bcba880b700aaead34e1b0810dff |
| tree | 39ea1f629e366c935d8dac6648e0f952078fd316 |
| parent | 1262d2f3ab2940c1d47ca4f5c4ca42e5c7259765 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 50 | }; |
| 51 | 51 | |
| 52 | 52 | const WORKSPACE_CHOICES: ReadonlyArray<{ |
| 53 | readonly mode: ThreadEnvMode; | |
| 53 | readonly mode: ThreadEnvMode | null; | |
| 54 | 54 | readonly label: string; |
| 55 | 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 | 64 | mode: "local", |
| 59 | 65 | label: "Current checkout", |
| ... | ... | @@ -126,6 +132,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 126 | 132 | const value = reference.settings[key]; |
| 127 | 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 | 139 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { |
| 130 | 140 | label: "environment settings update", |
| 131 | 141 | reportFailure: true, |
| ... | ... | @@ -219,17 +229,22 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 219 | 229 | <SettingsSection |
| 220 | 230 | title="Default workspace" |
| 221 | 231 | trailing={ |
| 222 | pendingWrites === 0 && uniform("defaultThreadEnvMode") === null ? ( | |
| 232 | pendingWrites === 0 && isMixed("defaultThreadEnvMode") ? ( | |
| 223 | 233 | <MixedValuesLabel projectSelected={projectSelected} /> |
| 224 | 234 | ) : 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 | 240 | <ChoiceRow |
| 229 | key={choice.mode} | |
| 241 | key={choice.mode ?? "inherit"} | |
| 230 | 242 | label={choice.label} |
| 231 | 243 | description={choice.description} |
| 232 | selected={uniform("defaultThreadEnvMode") === choice.mode} | |
| 244 | selected={ | |
| 245 | !isMixed("defaultThreadEnvMode") && | |
| 246 | uniform("defaultThreadEnvMode") === choice.mode | |
| 247 | } | |
| 233 | 248 | separated={index > 0} |
| 234 | 249 | disabled={disabledFor("defaultThreadEnvMode")} |
| 235 | 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 | 69 | expect(secondSettings.responseStreamingMode).toBe("token"); |
| 70 | 70 | }); |
| 71 | 71 | |
| 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 | 105 | it("resets only the selected page's override and rejects environment-wide writes", () => { |
| 73 | 106 | const settings: ServerSettings = { |
| 74 | 107 | ...DEFAULT_SERVER_SETTINGS, |
apps/mobile/src/features/settings/settings-scoped-server.ts+14-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | isNullableProjectSettingsOverride, | |
| 2 | 3 | PROJECT_SCOPED_SERVER_SETTING_KEYS, |
| 3 | 4 | type EnvironmentId, |
| 4 | 5 | type ProjectId, |
| ... | ... | @@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch( |
| 69 | 70 | continue; |
| 70 | 71 | const current = |
| 71 | 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 | 86 | const overrides = writes.get(target.environment.environmentId) ?? {}; |
| 74 | 87 | overrides[target.projectId] = next; |
| 75 | 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 | 20 | } from "@t3tools/contracts"; |
| 21 | 21 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 22 | 22 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 23 | import { | |
| 24 | isDefaultThreadEnvModeSettled, | |
| 25 | resolveDefaultThreadEnvMode, | |
| 26 | } from "@t3tools/shared/threadEnvMode"; | |
| 27 | 23 | import * as Arr from "effect/Array"; |
| 28 | 24 | import { pipe } from "effect/Function"; |
| 29 | 25 | |
| ... | ... | @@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 431 | 427 | : null, |
| 432 | 428 | ); |
| 433 | 429 | 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. | |
| 440 | 440 | const projectSettings = useMemo( |
| 441 | 441 | () => |
| 442 | 442 | resolveProjectSettings( |
| 443 | 443 | selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, |
| 444 | 444 | selectedProject?.id ?? null, |
| 445 | 445 | selectedProject, |
| 446 | t3ProjectFile, | |
| 446 | 447 | ), |
| 447 | [selectedEnvironmentServerConfig?.settings, selectedProject], | |
| 448 | [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], | |
| 448 | 449 | ); |
| 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; | |
| 466 | 459 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; |
| 467 | 460 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; |
| 468 | 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 | 20 | const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( |
| 21 | 21 | AsyncResult.initial<ProjectReadFileResult, never>(false), |
| 22 | 22 | ).pipe(Atom.withLabel("project-file-query:empty")); |
| 23 | function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { | |
| 23 | /** A pending in-app write to the file, overlaying the query until confirmed. */ | |
| 24 | export function optimisticFileAtom( | |
| 25 | environmentId: EnvironmentId, | |
| 26 | cwd: string, | |
| 27 | relativePath: string, | |
| 28 | ) { | |
| 24 | 29 | return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); |
| 25 | 30 | } |
| 26 | 31 |
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+14-31| ... | ... | @@ -1,13 +1,12 @@ |
| 1 | 1 | import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | EnvironmentId, | |
| 4 | 3 | type ModelSelection, |
| 5 | 4 | type ProviderInstanceId, |
| 6 | 5 | } from "@t3tools/contracts"; |
| 7 | 6 | import { createModelSelection } from "@t3tools/shared/model"; |
| 7 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | |
| 8 | 8 | import { useNavigate } from "@tanstack/react-router"; |
| 9 | 9 | |
| 10 | import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; | |
| 11 | 10 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; |
| 12 | 11 | import { |
| 13 | 12 | applyProviderInstanceSettings, |
| ... | ... | @@ -75,25 +74,16 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting |
| 75 | 74 | const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); |
| 76 | 75 | const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); |
| 77 | 76 | const modelSource = useScopedSettingSource(["defaultModelSelection"]); |
| 78 | const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]); | |
| 79 | 77 | const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; |
| 80 | 78 | 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; | |
| 97 | 87 | |
| 98 | 88 | function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { |
| 99 | 89 | const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); |
| ... | ... | @@ -292,27 +282,20 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting |
| 292 | 282 | title="Workspace" |
| 293 | 283 | description={ |
| 294 | 284 | 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." | |
| 300 | 287 | } |
| 301 | 288 | resetAction={ |
| 302 | settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? ( | |
| 289 | !isProjectScope && settings.defaultThreadEnvMode !== null ? ( | |
| 303 | 290 | <SettingResetButton |
| 304 | 291 | label="default workspace" |
| 305 | onClick={() => | |
| 306 | updateSettings({ | |
| 307 | defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, | |
| 308 | }) | |
| 309 | } | |
| 292 | onClick={() => updateSettings({ defaultThreadEnvMode: null })} | |
| 310 | 293 | /> |
| 311 | 294 | ) : null |
| 312 | 295 | } |
| 313 | 296 | control={ |
| 314 | 297 | <Select |
| 315 | value={mixedWorkspace ? null : settings.defaultThreadEnvMode} | |
| 298 | value={mixedWorkspace ? null : (effective?.defaultThreadEnvMode ?? null)} | |
| 316 | 299 | onValueChange={(value) => { |
| 317 | 300 | if (value === "local" || value === "worktree") |
| 318 | 301 | updateSettings({ defaultThreadEnvMode: value }); |
apps/web/src/components/settings/SettingInheritance.test.ts+37| ... | ... | @@ -54,4 +54,41 @@ describe("settingInheritanceLayers", () => { |
| 54 | 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 | 1 | import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | PROJECT_FILE_BACKED_SETTINGS, | |
| 4 | type ProjectFileBackedSettingKey, | |
| 3 | 5 | resolveEnvironmentMachineKind, |
| 4 | 6 | type ServerSettings, |
| 5 | 7 | } from "@t3tools/contracts"; |
| ... | ... | @@ -18,7 +20,7 @@ import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSetting |
| 18 | 20 | import { isProjectScopedSettingKey } from "./scopedSettings"; |
| 19 | 21 | |
| 20 | 22 | interface InheritanceLayer { |
| 21 | readonly key: "project" | "environment" | "built-in"; | |
| 23 | readonly key: "project" | "environment" | "t3.json" | "built-in"; | |
| 22 | 24 | readonly label: string; |
| 23 | 25 | readonly value: string; |
| 24 | 26 | readonly effective: boolean; |
| ... | ... | @@ -42,7 +44,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { |
| 42 | 44 | ? "Automatic" |
| 43 | 45 | : key === "sourceControlWriterModelSelection" |
| 44 | 46 | ? "Text generation model" |
| 45 | : "Not set"; | |
| 47 | : key === "defaultThreadEnvMode" | |
| 48 | ? "Inherit" | |
| 49 | : "Not set"; | |
| 46 | 50 | } |
| 47 | 51 | if (typeof value === "boolean") return value ? "On" : "Off"; |
| 48 | 52 | if (typeof value === "number") { |
| ... | ... | @@ -74,44 +78,65 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { |
| 74 | 78 | /** |
| 75 | 79 | * The layers a setting resolves through for one target, top-down: the |
| 76 | 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 | */ |
| 79 | 84 | export function settingInheritanceLayers( |
| 80 | 85 | target: ScopedSettingsTarget, |
| 81 | 86 | environmentSettings: ServerSettings, |
| 82 | 87 | key: keyof ServerSettings, |
| 83 | 88 | ): readonly InheritanceLayer[] { |
| 84 | const builtIn = DEFAULT_SERVER_SETTINGS[key]; | |
| 85 | 89 | 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); | |
| 88 | 93 | const layers: InheritanceLayer[] = []; |
| 89 | 94 | if (target.projectId !== null && isProjectScopedSettingKey(key)) { |
| 90 | 95 | layers.push({ |
| 91 | 96 | key: "project", |
| 92 | 97 | 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", | |
| 96 | 101 | }); |
| 97 | 102 | } |
| 98 | 103 | layers.push({ |
| 99 | 104 | key: "environment", |
| 100 | 105 | label: target.label, |
| 101 | 106 | value: environmentSet ? formatValue(key, environmentValue) : "Inherits", |
| 102 | effective: projectSource !== "project" && environmentSet, | |
| 107 | effective: source === "environment" && environmentSet, | |
| 103 | 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 | 124 | layers.push({ |
| 106 | 125 | key: "built-in", |
| 107 | 126 | label: "Default", |
| 108 | 127 | value: formatValue(key, builtIn), |
| 109 | effective: projectSource !== "project" && !environmentSet, | |
| 128 | effective: source === "environment" && !environmentSet, | |
| 110 | 129 | set: true, |
| 111 | 130 | }); |
| 112 | 131 | return layers; |
| 113 | 132 | } |
| 114 | 133 | |
| 134 | function isProjectFileBackedSettingKey( | |
| 135 | key: keyof ServerSettings, | |
| 136 | ): key is ProjectFileBackedSettingKey { | |
| 137 | return Object.hasOwn(PROJECT_FILE_BACKED_SETTINGS, key); | |
| 138 | } | |
| 139 | ||
| 115 | 140 | export type SettingInheritanceState = |
| 116 | 141 | | "default" |
| 117 | 142 | | "environment" |
apps/web/src/components/settings/SettingsScopeContext.tsx+59-3| ... | ... | @@ -1,22 +1,78 @@ |
| 1 | import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts"; | |
| 2 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; | |
| 3 | import { useAtomValue } from "@effect/atom-react"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 1 | 6 | import { createContext, type ReactNode, useContext, useMemo } from "react"; |
| 2 | 7 | |
| 3 | 8 | import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; |
| 9 | import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFilesQueryState"; | |
| 4 | 10 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; |
| 5 | 11 | import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings"; |
| 6 | 12 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; |
| 7 | 13 | |
| 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 | */ | |
| 19 | function 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 | ||
| 8 | 56 | function useResolvedSettingsScope(search: SettingsScopeSearch) { |
| 9 | 57 | const groups = useSettingsProjectGroups(); |
| 10 | 58 | const { environments: availableEnvironments } = useEnvironments(); |
| 11 | 59 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 60 | const scope = useMemo( | |
| 61 | () => resolveSettingsScope(search, groups, availableEnvironments), | |
| 62 | [availableEnvironments, groups, search], | |
| 63 | ); | |
| 64 | const projectFiles = useMemberProjectFiles(scope); | |
| 12 | 65 | return useMemo(() => { |
| 13 | const scope = resolveSettingsScope(search, groups, availableEnvironments); | |
| 14 | 66 | const selected = selectScopedSettingsEnvironments( |
| 15 | 67 | scope, |
| 16 | 68 | availableEnvironments, |
| 17 | 69 | primaryEnvironmentId, |
| 18 | 70 | ); |
| 19 | const targets = resolveScopedSettingsTargets(scope, selected.connectedEnvironments); | |
| 71 | const targets = resolveScopedSettingsTargets( | |
| 72 | scope, | |
| 73 | selected.connectedEnvironments, | |
| 74 | projectFiles, | |
| 75 | ); | |
| 20 | 76 | // The representative target supplies display values; project scopes |
| 21 | 77 | // prefer the member on the primary environment, like environments do. |
| 22 | 78 | const target = |
| ... | ... | @@ -26,7 +82,7 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) { |
| 26 | 82 | targets[0] ?? |
| 27 | 83 | null; |
| 28 | 84 | return { scope, groups, ...selected, targets, target }; |
| 29 | }, [availableEnvironments, groups, primaryEnvironmentId, search]); | |
| 85 | }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope]); | |
| 30 | 86 | } |
| 31 | 87 | |
| 32 | 88 | const SettingsScopeContext = createContext< |
apps/web/src/components/settings/scopedSettings.test.ts+47| ... | ... | @@ -510,6 +510,53 @@ describe("project overrides at environment scope", () => { |
| 510 | 510 | }); |
| 511 | 511 | }); |
| 512 | 512 | |
| 513 | describe("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 | ||
| 513 | 560 | describe("partial object patches at project scope", () => { |
| 514 | 561 | it("completes a writing style field patch from the target's effective value", () => { |
| 515 | 562 | const environmentId = EnvironmentId.make("laptop"); |
apps/web/src/components/settings/scopedSettings.ts+26-2| ... | ... | @@ -2,11 +2,13 @@ import { |
| 2 | 2 | ClientSettingsSchema, |
| 3 | 3 | type ClientSettingsPatch, |
| 4 | 4 | type EnvironmentId, |
| 5 | isNullableProjectSettingsOverride, | |
| 5 | 6 | PROJECT_SCOPED_SERVER_SETTING_KEYS, |
| 6 | 7 | type ProjectId, |
| 7 | 8 | type ProjectScopedServerSettingKey, |
| 8 | 9 | type ProjectSettingsOverrides, |
| 9 | 10 | ServerSettings, |
| 11 | type T3ProjectFile, | |
| 10 | 12 | type ServerSettingsPatch, |
| 11 | 13 | } from "@t3tools/contracts"; |
| 12 | 14 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| ... | ... | @@ -85,6 +87,9 @@ export interface ScopedSettingsTarget { |
| 85 | 87 | export function resolveScopedSettingsTargets( |
| 86 | 88 | scope: ResolvedSettingsScope, |
| 87 | 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 | 93 | ): readonly ScopedSettingsTarget[] { |
| 89 | 94 | const byId = new Map( |
| 90 | 95 | connectedEnvironments.map((environment) => [environment.environmentId, environment]), |
| ... | ... | @@ -93,7 +98,11 @@ export function resolveScopedSettingsTargets( |
| 93 | 98 | return scope.members.flatMap((member) => { |
| 94 | 99 | const environment = byId.get(member.environmentId); |
| 95 | 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 | 106 | return [ |
| 98 | 107 | { |
| 99 | 108 | environmentId: member.environmentId, |
| ... | ... | @@ -143,7 +152,13 @@ export function scopedSettingsSource( |
| 143 | 152 | const scoped = keys.filter(isProjectScopedSettingKey); |
| 144 | 153 | if (scoped.length === 0 || targets.length === 0) return "environment"; |
| 145 | 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 | } |
| 148 | 163 | |
| 149 | 164 | interface ScopedServerWrite { |
| ... | ... | @@ -234,6 +249,15 @@ export function planScopedSettingsPatch( |
| 234 | 249 | }; |
| 235 | 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 | 261 | const base = effective[key as keyof ServerSettings]; |
| 238 | 262 | next[key] = |
| 239 | 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 | 407 | ? { state: "mixed", summary: "Mixed across selected environments" } |
| 408 | 408 | : source === "project" |
| 409 | 409 | ? { 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" }; | |
| 415 | 417 | const renderedInheritance = |
| 416 | 418 | context && serverScoped && settingKeys.length > 0 ? ( |
| 417 | 419 | <SettingInheritance |
apps/web/src/hooks/useHandleNewThread.test.ts+16-9| ... | ... | @@ -100,18 +100,25 @@ vi.mock("@t3tools/contracts", () => ({ |
| 100 | 100 | vi.mock("@t3tools/shared/projectSettings", () => ({ |
| 101 | 101 | // Environment settings pass through; the tests set project fields on the |
| 102 | 102 | // 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 | }, | |
| 105 | 118 | sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, |
| 106 | 119 | overrides: {}, |
| 107 | 120 | }), |
| 108 | 121 | })); |
| 109 | vi.mock("@t3tools/shared/threadEnvMode", () => ({ | |
| 110 | resolveDefaultThreadEnvMode: (input: { | |
| 111 | readonly projectFile: "local" | "worktree" | null; | |
| 112 | readonly globalDefault: "local" | "worktree"; | |
| 113 | }) => input.projectFile ?? input.globalDefault, | |
| 114 | })); | |
| 115 | 122 | vi.mock("@tanstack/react-router", () => ({ |
| 116 | 123 | useParams: () => null, |
| 117 | 124 | useRouter: () => testState.router, |
| ... | ... | @@ -137,7 +144,7 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ |
| 137 | 144 | resolveNewThreadModelSelectionOverride: () => null, |
| 138 | 145 | })); |
| 139 | 146 | vi.mock("../lib/t3ProjectFileDefaults", () => ({ |
| 140 | readT3ProjectFileDefaultThreadEnvMode: () => testState.projectFileRead, | |
| 147 | readT3ProjectFile: () => testState.projectFileRead, | |
| 141 | 148 | })); |
| 142 | 149 | vi.mock("../lib/utils", () => ({ |
| 143 | 150 | newDraftId: () => "draft-delayed", |
apps/web/src/hooks/useHandleNewThread.ts+12-17| ... | ... | @@ -23,14 +23,13 @@ import { |
| 23 | 23 | selectProjectGroupingSettings, |
| 24 | 24 | } from "../logicalProject"; |
| 25 | 25 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 26 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; | |
| 27 | 26 | import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; |
| 28 | 27 | import { |
| 29 | 28 | hasExplicitComposerModelSelection, |
| 30 | 29 | resolveNewDraftStartFromOrigin, |
| 31 | 30 | resolveNewThreadModelSelectionOverride, |
| 32 | 31 | } from "../lib/chatThreadActions"; |
| 33 | import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; | |
| 32 | import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; | |
| 34 | 33 | import { environmentServerConfigsAtom } from "../state/server"; |
| 35 | 34 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 36 | 35 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| ... | ... | @@ -138,10 +137,6 @@ export function useNewThreadHandler() { |
| 138 | 137 | ); |
| 139 | 138 | const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; |
| 140 | 139 | const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; |
| 141 | const projectThreadEnvMode = | |
| 142 | projectSettings.sources.defaultThreadEnvMode === "project" | |
| 143 | ? projectSettings.settings.defaultThreadEnvMode | |
| 144 | : undefined; | |
| 145 | 140 | const resolveModelSelectionOverride = (destinationDraftId: DraftId) => |
| 146 | 141 | resolveNewThreadModelSelectionOverride({ |
| 147 | 142 | projectDefaultSelection: projectDefaultModelSelection ?? null, |
| ... | ... | @@ -154,17 +149,17 @@ export function useNewThreadHandler() { |
| 154 | 149 | // skipped entirely when a higher-priority source decides, and its |
| 155 | 150 | // query atom caches per project after the first call. |
| 156 | 151 | 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; | |
| 168 | 163 | }; |
| 169 | 164 | const logicalProjectKey = project |
| 170 | 165 | ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) |
apps/web/src/lib/t3ProjectFileDefaults.ts+9-9| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { T3_PROJECT_FILE_NAME, type EnvironmentId, type ThreadEnvMode } from "@t3tools/contracts"; | |
| 1 | import { T3_PROJECT_FILE_NAME, type EnvironmentId, type T3ProjectFile } from "@t3tools/contracts"; | |
| 2 | 2 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 3 | 3 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 4 | 4 | |
| ... | ... | @@ -9,19 +9,19 @@ import { |
| 9 | 9 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 10 | 10 | |
| 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-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 | |
| 18 | 18 | * `useProjectFileQuery` renders. Missing, truncated, or invalid files |
| 19 | 19 | * resolve to null. |
| 20 | 20 | */ |
| 21 | export async function readT3ProjectFileDefaultThreadEnvMode( | |
| 21 | export async function readT3ProjectFile( | |
| 22 | 22 | environmentId: EnvironmentId, |
| 23 | 23 | workspaceRoot: string, |
| 24 | ): Promise<ThreadEnvMode | null> { | |
| 24 | ): Promise<T3ProjectFile | null> { | |
| 25 | 25 | const result = await executeAtomQuery( |
| 26 | 26 | appAtomRegistry, |
| 27 | 27 | getProjectFileQueryAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME), |
| ... | ... | @@ -34,5 +34,5 @@ export async function readT3ProjectFileDefaultThreadEnvMode( |
| 34 | 34 | result._tag === "Success" ? result.value : null, |
| 35 | 35 | ); |
| 36 | 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 | 49 | on each selected environment, and reset returns to the environment's shared list. A project's |
| 50 | 50 | `t3.json` actions can be imported there. |
| 51 | 51 | |
| 52 | For workspace mode, a project's `t3.json` preference applies when the project has no override. | |
| 52 | Settings a repository can also declare in `t3.json`, such as the workspace for new threads, | |
| 53 | resolve in one order: a project override, then the environment setting, then `t3.json`, then the | |
| 54 | built-in default. Leave a setting on **Inherit** to let the next tier decide. | |
| 53 | 55 | Browser access changes apply when an agent session next starts. |
| 54 | 56 | |
| 55 | 57 | New 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 | 82 | ); |
| 83 | 83 | }; |
| 84 | 84 | |
| 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 | */ | |
| 91 | export 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 | ||
| 85 | 110 | export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => { |
| 86 | 111 | const decodeElement = Schema.decodeUnknownOption(element as never); |
| 87 | 112 | return Schema.Array(Schema.Unknown).pipe( |
packages/contracts/src/settings.test.ts+21| ... | ... | @@ -805,6 +805,27 @@ describe("provider enabled defaults", () => { |
| 805 | 805 | }); |
| 806 | 806 | |
| 807 | 807 | describe("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 | 829 | it("defaults start-from-origin on for legacy configs", () => { |
| 809 | 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 | 5 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 6 | 6 | import { |
| 7 | 7 | ForwardCompatibleNullable, |
| 8 | OmittedWhenNull, | |
| 8 | 9 | ProjectId, |
| 9 | 10 | TrimmedNonEmptyString, |
| 10 | 11 | TrimmedString, |
| ... | ... | @@ -1041,6 +1042,26 @@ export const ProjectSettingsOverrides = Schema.Struct({ |
| 1041 | 1042 | } satisfies Record<ProjectScopedServerSettingKey, unknown>); |
| 1042 | 1043 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; |
| 1043 | 1044 | |
| 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 | */ | |
| 1051 | export function isNullableProjectSettingsOverride(key: ProjectScopedServerSettingKey): boolean { | |
| 1052 | return NULLABLE_PROJECT_SETTINGS_OVERRIDES.has(key); | |
| 1053 | } | |
| 1054 | const 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 | ||
| 1044 | 1065 | export const StorageCleanupSettings = Schema.Struct({ |
| 1045 | 1066 | worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 1046 | 1067 | worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| ... | ... | @@ -1176,9 +1197,14 @@ export const ServerSettings = Schema.Struct({ |
| 1176 | 1197 | environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe( |
| 1177 | 1198 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1178 | 1199 | ), |
| 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), | |
| 1182 | 1208 | newWorktreesStartFromOrigin: Schema.Boolean.pipe( |
| 1183 | 1209 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 1184 | 1210 | ), |
| ... | ... | @@ -1471,7 +1497,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1471 | 1497 | providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 1472 | 1498 | backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), |
| 1473 | 1499 | environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), |
| 1474 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), | |
| 1500 | defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), | |
| 1475 | 1501 | newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), |
| 1476 | 1502 | addProjectBaseDirectory: Schema.optionalKey(TrimmedString), |
| 1477 | 1503 | textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), |
packages/contracts/src/t3ProjectFile.ts+35| ... | ... | @@ -3,6 +3,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 3 | 3 | |
| 4 | 4 | import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; |
| 5 | 5 | import { ProjectScriptIcon } from "./orchestration.ts"; |
| 6 | import type { ProjectScopedServerSettingKey, ServerSettings } from "./settings.ts"; | |
| 6 | 7 | |
| 7 | 8 | /** File name of the checked-in T3 project file, resolved at the workspace root. */ |
| 8 | 9 | export const T3_PROJECT_FILE_NAME = "t3.json"; |
| ... | ... | @@ -105,3 +106,37 @@ export const T3ProjectFile = Schema.Struct({ |
| 105 | 106 | "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.", |
| 106 | 107 | }); |
| 107 | 108 | export 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 | */ | |
| 119 | export 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 | }; | |
| 133 | export 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 | */ | |
| 140 | export 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 | 127 | "types": "./src/projectSettings.ts", |
| 128 | 128 | "import": "./src/projectSettings.ts" |
| 129 | 129 | }, |
| 130 | "./threadEnvMode": { | |
| 131 | "types": "./src/threadEnvMode.ts", | |
| 132 | "import": "./src/threadEnvMode.ts" | |
| 133 | }, | |
| 134 | 130 | "./t3ProjectFile": { |
| 135 | 131 | "types": "./src/t3ProjectFile.ts", |
| 136 | 132 | "import": "./src/t3ProjectFile.ts" |
packages/shared/src/projectSettings.test.ts+76| ... | ... | @@ -30,6 +30,24 @@ describe("resolveProjectSettings", () => { |
| 30 | 30 | ); |
| 31 | 31 | }); |
| 32 | 32 | |
| 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 | 51 | it("treats a null project like an absent one before the shell snapshot arrives", () => { |
| 34 | 52 | // The mobile new-task flow resolves settings while its selected project is |
| 35 | 53 | // still null; reading the aggregate's legacy fields off null crashed launch. |
| ... | ... | @@ -118,6 +136,64 @@ describe("resolveProjectSettings", () => { |
| 118 | 136 | }); |
| 119 | 137 | }); |
| 120 | 138 | |
| 139 | describe("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 | ||
| 121 | 197 | describe("projectSettingsOverrides patches", () => { |
| 122 | 198 | it("replaces a project's entry, removes it with null, and drops empty entries", () => { |
| 123 | 199 | const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
packages/shared/src/projectSettings.ts+68-4| ... | ... | @@ -1,24 +1,34 @@ |
| 1 | 1 | import { |
| 2 | 2 | type ModelSelection, |
| 3 | PROJECT_FILE_BACKED_SETTINGS, | |
| 3 | 4 | PROJECT_SCOPED_SERVER_SETTING_KEYS, |
| 4 | 5 | type ProjectId, |
| 5 | 6 | type ProjectScopedServerSettingKey, |
| 6 | 7 | type ProjectSettingsOverrides, |
| 8 | type ResolvedServerSettings, | |
| 7 | 9 | type ServerSettings, |
| 10 | type T3ProjectFile, | |
| 8 | 11 | type ThreadEnvMode, |
| 9 | 12 | type WorktreeCleanupRules, |
| 10 | 13 | } from "@t3tools/contracts"; |
| 11 | 14 | import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; |
| 12 | 15 | |
| 13 | export 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 | */ | |
| 23 | export type ProjectSettingSource = "environment" | "project" | "t3.json"; | |
| 14 | 24 | |
| 15 | 25 | export type ProjectSettingSources = Readonly< |
| 16 | 26 | Record<ProjectScopedServerSettingKey, ProjectSettingSource> |
| 17 | 27 | >; |
| 18 | 28 | |
| 19 | export interface ResolvedProjectSettings { | |
| 29 | export interface ResolvedProjectSettings<Settings extends ServerSettings = ServerSettings> { | |
| 20 | 30 | /** Environment settings with the project's overrides applied. */ |
| 21 | readonly settings: ServerSettings; | |
| 31 | readonly settings: Settings; | |
| 22 | 32 | /** Where each scopable key's effective value came from. */ |
| 23 | 33 | readonly sources: ProjectSettingSources; |
| 24 | 34 | /** The project's raw override entry; `{}` when it has none. */ |
| ... | ... | @@ -36,7 +46,9 @@ export function hasProjectSettingsOverrides( |
| 36 | 46 | settings: Pick<ServerSettings, "projectSettingsOverrides">, |
| 37 | 47 | ): boolean { |
| 38 | 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 | 53 | return false; |
| 42 | 54 | } |
| ... | ... | @@ -63,6 +75,55 @@ export function resolveProjectSettings( |
| 63 | 75 | // Nullable, not just optional: the mobile new-task flow passes its selected |
| 64 | 76 | // project straight through, and that is null until the shell snapshot lands. |
| 65 | 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 | */ | |
| 85 | export function resolveProjectSettings( | |
| 86 | settings: ServerSettings, | |
| 87 | projectId: ProjectId | null, | |
| 88 | project: LegacyProjectSettingsFields | null | undefined, | |
| 89 | projectFile: T3ProjectFile | null, | |
| 90 | ): ResolvedProjectSettings<ResolvedServerSettings>; | |
| 91 | export 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 | ||
| 101 | function 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 | ||
| 123 | function resolveProjectOverrides( | |
| 124 | settings: ServerSettings, | |
| 125 | projectId: ProjectId | null, | |
| 126 | project?: LegacyProjectSettingsFields | null, | |
| 66 | 127 | ): ResolvedProjectSettings { |
| 67 | 128 | const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId]; |
| 68 | 129 | const overrides: ProjectSettingsOverrides = |
| ... | ... | @@ -87,6 +148,9 @@ export function resolveProjectSettings( |
| 87 | 148 | for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) { |
| 88 | 149 | if (!Object.hasOwn(overrides, key)) continue; |
| 89 | 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 | 154 | // A model on a disabled provider falls back to the environment, like the |
| 91 | 155 | // environment-level guards do for these keys. |
| 92 | 156 | if ( |
packages/shared/src/threadEnvMode.test.ts deleted-65| ... | ... | @@ -1,65 +0,0 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { isDefaultThreadEnvModeSettled, resolveDefaultThreadEnvMode } from "./threadEnvMode.ts"; | |
| 4 | ||
| 5 | describe("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 | ||
| 31 | describe("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 @@ |
| 1 | import 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 | */ | |
| 12 | export 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 | */ | |
| 26 | export 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 | } |