Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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;
}> = [
{
mode: null,
label: "Inherit",
description:
"Follow the next tier: the environment, 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,20 @@ 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) => (
<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
58 changes: 26 additions & 32 deletions apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
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";
Expand Down Expand Up @@ -75,25 +76,28 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
const modelSource = useScopedSettingSource(["defaultModelSelection"]);
const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]);
const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
const unavailable = connectedEnvironments.length === 0;

// A checkout's t3.json wins over the environment default when the project
// has no override of its own; show which one "inherit" resolves to.
// A setting on either scope beats the checkout's t3.json, and "inherit"
// (null) is what lets the file decide; show what inherit resolves to once
// a checkout's file has been read. Other scopes have no single file to ask.
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 fileSettled = checkout !== null && t3File.status !== "loading";
// The same resolver the new-thread paths use, so the label cannot disagree
// with what a thread would actually get.
const withFile = fileSettled
? resolveProjectSettings(settings, checkout.id, null, t3File.file)
: null;
const inheritedEnvModeLabel =
workspaceSource === "project"
withFile === null || settings.defaultThreadEnvMode !== null
? null
: repositoryEnvMode
? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)`
: null;
: `${resolveEnvModeLabel(withFile.settings.defaultThreadEnvMode)}${withFile.sources.defaultThreadEnvMode === "t3.json" ? " (t3.json)" : ""}`;

function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {
const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
Expand Down Expand Up @@ -292,44 +296,34 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
title="Workspace"
description={
isProjectScope
? "Where new threads in this project start. A t3.json preference applies when the project has no override."
: "Where new threads start, unless overridden by the project or t3.json."
}
status={
inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined
}
resetAction={
settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? (
<SettingResetButton
label="default workspace"
onClick={() =>
updateSettings({
defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode,
})
}
/>
) : null
? "Where new threads in this project start. Inherit follows the environment, then the repository's t3.json."
: "Where new threads start. Inherit follows the repository's t3.json, or the current checkout."
}
status={inheritedEnvModeLabel ? `Resolves to: ${inheritedEnvModeLabel}` : undefined}
control={
<Select
value={mixedWorkspace ? null : settings.defaultThreadEnvMode}
value={mixedWorkspace ? null : (settings.defaultThreadEnvMode ?? "inherit")}
onValueChange={(value) => {
if (value === "local" || value === "worktree")
if (value === "inherit") updateSettings({ defaultThreadEnvMode: null });
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
else if (value === "local" || value === "worktree")
updateSettings({ defaultThreadEnvMode: value });
}}
>
<SelectTrigger size="sm" aria-label="Default workspace">
<SelectValue>
{(value: string | null) =>
value === "local" || value === "worktree"
? resolveEnvModeLabel(value)
: unavailable
? "Unavailable"
: "Mixed"
value === "inherit"
? "Inherit"
: value === "local" || value === "worktree"
? resolveEnvModeLabel(value)
: unavailable
? "Unavailable"
: "Mixed"
}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem value="inherit">Inherit</SelectItem>
<SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
<SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
</SelectPopup>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/settings/SettingInheritance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
? "Automatic"
: key === "sourceControlWriterModelSelection"
? "Text generation model"
: "Not set";
: key === "defaultThreadEnvMode"
? "Inherit"
: "Not set";
}
if (typeof value === "boolean") return value ? "On" : "Off";
if (typeof value === "number") {
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/components/settings/scopedSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,53 @@ describe("project overrides at environment scope", () => {
});
});

describe("null patches at project scope", () => {
it("removes the override for keys that cannot store null and keeps it for keys that can", () => {
const environmentId = EnvironmentId.make("laptop");
const projectId = ProjectId.make("fleet");
const scope = {
kind: "project" as const,
group: {} as never,
environmentId: null,
label: "fleet",
members: [{ id: projectId, environmentId } as never],
environmentIds: [environmentId],
};
const environments = [
{
environmentId,
label: "Laptop",
connection: { phase: "connected" as const },
serverConfig: {
settings: {
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides: {
[projectId]: { defaultThreadEnvMode: "worktree" as const, defaultAutoPull: true },
},
},
environment: { capabilities: { projectSettingsOverrides: true } },
},
},
];
expect(
planScopedSettingsPatch(scope, environments, { defaultThreadEnvMode: null }).serverWrites[0]
?.patch,
).toEqual({ projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } } });
expect(
planScopedSettingsPatch(scope, environments, { defaultModelSelection: null }).serverWrites[0]
?.patch,
).toEqual({
projectSettingsOverrides: {
[projectId]: {
defaultThreadEnvMode: "worktree",
defaultAutoPull: true,
defaultModelSelection: null,
},
},
});
});
});

describe("partial object patches at project scope", () => {
it("completes a writing style field patch from the target's effective value", () => {
const environmentId = EnvironmentId.make("laptop");
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/settings/scopedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ClientSettingsSchema,
type ClientSettingsPatch,
type EnvironmentId,
isNullableProjectSettingsOverride,
PROJECT_SCOPED_SERVER_SETTING_KEYS,
type ProjectId,
type ProjectScopedServerSettingKey,
Expand Down Expand Up @@ -234,6 +235,15 @@ export function planScopedSettingsPatch(
};
continue;
}
// 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];
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
continue;
}
const base = effective[key as keyof ServerSettings];
next[key] =
isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
Expand Down
Loading
Loading