Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,16 @@ const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettin
};

const WORKSPACE_CHOICES: ReadonlyArray<{
readonly mode: ThreadEnvMode;
readonly mode: ThreadEnvMode | null;
readonly label: string;
readonly description: string;
}> = [
// Only offered at environment scope; a project falls back through "Use defaults".
{
mode: null,
label: "Inherit",
description: "Use the repository's t3.json, or the current checkout.",
},
{
mode: "local",
label: "Current checkout",
Expand Down Expand Up @@ -126,6 +132,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
const value = reference.settings[key];
return displayTargets.every((entry) => entry.settings[key] === value) ? value : null;
};
// `uniform` folds a real null into "mixed"; nullable keys need the distinction.
const isMixed = (key: keyof ServerSettings) =>
reference === null ||
displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]);
const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
label: "environment settings update",
reportFailure: true,
Expand Down Expand Up @@ -219,17 +229,22 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
<SettingsSection
title="Default workspace"
trailing={
pendingWrites === 0 && uniform("defaultThreadEnvMode") === null ? (
pendingWrites === 0 && isMixed("defaultThreadEnvMode") ? (
<MixedValuesLabel projectSelected={projectSelected} />
) : null
}
>
{WORKSPACE_CHOICES.map((choice, index) => (
{WORKSPACE_CHOICES.filter(
(choice) => choice.mode !== null || !projectSelected,
).map((choice, index) => (
<ChoiceRow
key={choice.mode}
key={choice.mode ?? "inherit"}
label={choice.label}
description={choice.description}
selected={uniform("defaultThreadEnvMode") === choice.mode}
selected={
!isMixed("defaultThreadEnvMode") &&
uniform("defaultThreadEnvMode") === choice.mode
}
separated={index > 0}
disabled={disabledFor("defaultThreadEnvMode")}
onPress={() => write({ defaultThreadEnvMode: choice.mode })}
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/features/settings/settings-scoped-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,39 @@ describe("mobile project settings scope", () => {
expect(secondSettings.responseStreamingMode).toBe("token");
});

it("removes a project override when a picker sends null for a key that cannot store it", () => {
const settings: ServerSettings = {
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides: {
[firstProject]: { defaultThreadEnvMode: "worktree", defaultAutoPull: true },
},
};
const targets = resolveMobileSettingsTargets(
[environment(firstId, settings)],
[{ environmentId: firstId, id: firstProject }],
);
expect(planMobileScopedSettingsPatch(targets, true, { defaultThreadEnvMode: null })).toEqual([
{
environmentId: firstId,
patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } },
},
]);
expect(planMobileScopedSettingsPatch(targets, true, { defaultModelSelection: null })).toEqual([
{
environmentId: firstId,
patch: {
projectSettingsOverrides: {
[firstProject]: {
defaultThreadEnvMode: "worktree",
defaultAutoPull: true,
defaultModelSelection: null,
},
},
},
},
]);
});

it("resets only the selected page's override and rejects environment-wide writes", () => {
const settings: ServerSettings = {
...DEFAULT_SERVER_SETTINGS,
Expand Down
15 changes: 14 additions & 1 deletion apps/mobile/src/features/settings/settings-scoped-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
isNullableProjectSettingsOverride,
PROJECT_SCOPED_SERVER_SETTING_KEYS,
type EnvironmentId,
type ProjectId,
Expand Down Expand Up @@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch(
continue;
const current =
target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {};
const next = { ...current, ...patch };
const next: Record<string, unknown> = { ...current };
for (const [key, value] of Object.entries(patch)) {
// A picker's "Inherit" sends null; for keys whose override cannot
// store null that means remove the override.
if (
value === null &&
!isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey)
) {
delete next[key];
} else {
next[key] = value;
}
}
const overrides = writes.get(target.environment.environmentId) ?? {};
overrides[target.projectId] = next;
writes.set(target.environment.environmentId, overrides);
Expand Down
49 changes: 21 additions & 28 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ import {
} from "@t3tools/contracts";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
import {
isDefaultThreadEnvModeSettled,
resolveDefaultThreadEnvMode,
} from "@t3tools/shared/threadEnvMode";
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";

Expand Down Expand Up @@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
: null,
);
const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null;
const t3ProjectFileDefaultMode = useMemo(() => {
if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null;
return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null;
}, [t3ProjectFileData]);
// Environment settings with the project's overrides applied; the
// aggregate's own legacy fields still count until the server folds them.
const t3ProjectFile = useMemo(
() =>
t3ProjectFileData === null || t3ProjectFileData.truncated
? null
: parseT3ProjectFile(t3ProjectFileData.contents),
[t3ProjectFileData],
);
// Environment settings with the project's overrides and its t3.json
// applied; the aggregate's own legacy fields still count until the server
// folds them.
const projectSettings = useMemo(
() =>
resolveProjectSettings(
selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
selectedProject?.id ?? null,
selectedProject,
t3ProjectFile,
),
[selectedEnvironmentServerConfig?.settings, selectedProject],
[selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile],
);
const projectThreadEnvMode =
projectSettings.sources.defaultThreadEnvMode === "project"
? projectSettings.settings.defaultThreadEnvMode
: undefined;
const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({
projectSetting: projectThreadEnvMode,
projectFile: t3ProjectFileDefaultMode,
globalDefault: projectSettings.settings.defaultThreadEnvMode,
});
// While unsettled the resolved default is provisional. Nothing may write
// it into the draft during that window (the auto-branch effect does), or
// the frozen interim value beats the t3.json default once it loads.
const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({
explicitMode: selectedProjectDraft.workspaceSelection?.mode,
projectSetting: projectThreadEnvMode,
projectFilePending: t3ProjectFileQuery.isPending,
});
const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode;
// While the file read is pending and nothing above it decided, the
// resolved default is provisional. Nothing may write it into the draft
// during that window (the auto-branch effect does), or the frozen interim
// value beats the t3.json default once it loads.
const defaultWorkspaceModeSettled =
selectedProjectDraft.workspaceSelection?.mode !== undefined ||
projectSettings.sources.defaultThreadEnvMode !== "environment" ||
!t3ProjectFileQuery.isPending;
const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/files/projectFilesQueryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ const EMPTY_PROJECT_FILE_PATH = "";
const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make(
AsyncResult.initial<ProjectReadFileResult, never>(false),
).pipe(Atom.withLabel("project-file-query:empty"));
function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) {
/** A pending in-app write to the file, overlaying the query until confirmed. */
export function optimisticFileAtom(
environmentId: EnvironmentId,
cwd: string,
relativePath: string,
) {
return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });
}

Expand Down
45 changes: 14 additions & 31 deletions apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import {
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
type ModelSelection,
type ProviderInstanceId,
} from "@t3tools/contracts";
import { createModelSelection } from "@t3tools/shared/model";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { useNavigate } from "@tanstack/react-router";

import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
import { getCustomModelOptionsByInstance } from "../../modelSelection";
import {
applyProviderInstanceSettings,
Expand Down Expand Up @@ -75,25 +74,16 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
const modelSource = useScopedSettingSource(["defaultModelSelection"]);
const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]);
const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
const unavailable = connectedEnvironments.length === 0;

// A checkout's t3.json wins over the environment default when the project
// has no override of its own; show which one "inherit" resolves to.
const checkout = scope.kind === "checkout" ? scope.checkout : null;
// The query is disabled without a checkout, so any id satisfies the hook.
const t3File = useT3ProjectFileState(
checkout?.environmentId ?? EnvironmentId.make("none"),
category === "general" && checkout ? checkout.workspaceRoot : null,
);
const repositoryEnvMode = t3File.file?.defaultThreadEnvMode ?? null;
const inheritedEnvModeLabel =
workspaceSource === "project"
? null
: repositoryEnvMode
? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)`
: null;
// File-backed keys show their effective value; the target already carries
// the checkout's t3.json, and a null file here only fills the built-in.
// The reset arrow beside the title clears the tier (SettingsRow handles a
// project override, the environment value is cleared here), so the picker
// has no "inherit" item.
const effective = target
? resolveProjectSettings(target.settings, null, null, null).settings
: null;

function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {
const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
Expand Down Expand Up @@ -292,27 +282,20 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
title="Workspace"
description={
isProjectScope
? "Where new threads in this project start. A t3.json preference applies when the project has no override."
: "Where new threads start, unless overridden by the project or t3.json."
}
status={
inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined
? "Where new threads in this project start."
: "Where new threads start. Projects and their t3.json can override it."
}
resetAction={
settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? (
!isProjectScope && settings.defaultThreadEnvMode !== null ? (
<SettingResetButton
label="default workspace"
onClick={() =>
updateSettings({
defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode,
})
}
onClick={() => updateSettings({ defaultThreadEnvMode: null })}
/>
) : null
}
control={
<Select
value={mixedWorkspace ? null : settings.defaultThreadEnvMode}
value={mixedWorkspace ? null : (effective?.defaultThreadEnvMode ?? null)}
onValueChange={(value) => {
if (value === "local" || value === "worktree")
updateSettings({ defaultThreadEnvMode: value });
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/components/settings/SettingInheritance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,41 @@ describe("settingInheritanceLayers", () => {
["Off", false],
]);
});

it("shows the checkout's t3.json as a layer for file-backed keys", () => {
const file = { defaultThreadEnvMode: "worktree" as const };
const fromFile = settingInheritanceLayers(
{
environmentId,
label: "Laptop",
projectId,
...resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, file),
},
DEFAULT_SERVER_SETTINGS,
"defaultThreadEnvMode",
);
expect(fromFile.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([
["Project", "Inherits", false],
["Laptop", "Inherits", false],
["t3.json", "New worktree", true],
["Default", "Current checkout", false],
]);
const settings = { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "local" as const };
const fromEnvironment = settingInheritanceLayers(
{
environmentId,
label: "Laptop",
projectId,
...resolveProjectSettings(settings, projectId, null, file),
},
settings,
"defaultThreadEnvMode",
);
expect(fromEnvironment.map((layer) => [layer.value, layer.effective])).toEqual([
["Inherits", false],
["Current checkout", true],
["Inherits", false],
["Current checkout", false],
]);
});
});
Loading
Loading