diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index 3ea92f50e..788e09bba 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -1,3 +1,4 @@
+import type { ServerSettings } from "@t3tools/contracts";
import { useAuth, useUser } from "@clerk/expo";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import Constants from "expo-constants";
@@ -43,13 +44,10 @@ import {
DEFAULT_SERVER_SETTINGS,
MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
- type ServerSettingsPatch,
} from "@t3tools/contracts";
import {
filterSharedServerPatch,
- findSharedSettingsMismatches,
supportsSharedSettingsSync,
- pickSharedServerSettings,
} from "@t3tools/client-runtime/state/shared-settings";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import {
@@ -67,6 +65,7 @@ import {
resolveAgentAwarenessSignInMessage,
resolveAgentAwarenessSubtitle,
} from "./SettingsRouteScreen.logic";
+import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
@@ -622,6 +621,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD
* has no primary environment, so the first eligible environment that
* supports restart continuation when available is the reference value. Edits fan out to every eligible
* environment, and a mismatch row lets the user push the reference out.
+ * Mobile edits auto-settle defaults across connected, capable environments.
+ * The first target supplies the displayed values. Applying them leaves each
+ * environment's other defaults and overrides intact.
*/
function SharedThreadSettingsRows() {
const { environments } = useEnvironments();
@@ -646,7 +648,9 @@ function SharedThreadSettingsRows() {
return null;
}
- const writeToAll = (patch: ServerSettingsPatch) => {
+ const writeToAll = (
+ patch: Partial>,
+ ) => {
for (const environment of syncTargets) {
const supportedPatch = filterSharedServerPatch(
patch,
@@ -661,20 +665,22 @@ function SharedThreadSettingsRows() {
}
};
- const mismatches = findSharedSettingsMismatches({
- primaryEnvironmentId: reference.environmentId,
- primarySettings: referenceSettings,
- primaryCapabilities: reference.serverConfig?.environment.capabilities,
- environments: environments.map((environment) => ({
+ const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync(
+ { environmentId: reference.environmentId, settings: referenceSettings },
+ syncTargets.map((environment) => ({
environmentId: environment.environmentId,
label: environment.label,
- syncEligible: supportsSharedSettingsSync(environment),
settings: environment.serverConfig?.settings ?? null,
- capabilities: environment.serverConfig?.environment.capabilities,
})),
- });
+ );
const afterDays = referenceSettings.sidebarAutoSettleAfterDays;
+ const continuationMismatches = syncTargets.filter(
+ (environment) =>
+ environment.serverConfig?.environment.capabilities.threadRestartContinuation === true &&
+ environment.serverConfig.settings.continueThreadsAfterServerUpdate !==
+ referenceSettings.continueThreadsAfterServerUpdate,
+ );
const commitDays = () => {
const draft = (daysDraft ?? "").trim();
setDaysDraft(null);
@@ -735,7 +741,7 @@ function SharedThreadSettingsRows() {
{mismatches.length > 0 ? (
- Settings differ
+ Auto-settle defaults differ
{mismatches.map((mismatch) => mismatch.label).join(", ")}
@@ -743,30 +749,40 @@ function SharedThreadSettingsRows() {
{
- const patch = pickSharedServerSettings(
- referenceSettings,
- reference.serverConfig?.environment.capabilities,
- );
for (const mismatch of mismatches) {
- const target = environments.find(
- (candidate) => candidate.environmentId === mismatch.environmentId,
- );
void updateSettings({
environmentId: mismatch.environmentId,
- input: {
- patch: filterSharedServerPatch(
- patch,
- target?.serverConfig?.environment.capabilities,
- target?.serverConfig?.settings,
- referenceSettings,
- ),
- },
+ input: { patch: autoSettlePatch },
});
}
}}
className="rounded-full bg-subtle px-4 py-2 active:opacity-70"
>
- Apply to all
+
+ Apply auto-settle defaults
+
+
+
+ ) : null}
+ {continuationMismatches.length > 0 ? (
+
+
+ Restart continuation defaults differ
+
+ {continuationMismatches.map((environment) => environment.label).join(", ")}
+
+
+
+ writeToAll({
+ continueThreadsAfterServerUpdate:
+ referenceSettings.continueThreadsAfterServerUpdate,
+ })
+ }
+ className="rounded-full bg-subtle px-4 py-2 active:opacity-70"
+ >
+ Apply restart defaults
) : null}
diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts
new file mode 100644
index 000000000..ec550725a
--- /dev/null
+++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts
@@ -0,0 +1,78 @@
+import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync";
+
+const reference = {
+ environmentId: EnvironmentId.make("reference"),
+ settings: {
+ ...DEFAULT_SERVER_SETTINGS,
+ sidebarAutoSettleAfterDays: 7,
+ sidebarAutoSettleOnMerge: true,
+ newWorktreesStartFromOrigin: false,
+ continueThreadsAfterServerUpdate: false,
+ },
+};
+
+describe("auto-settle settings sync", () => {
+ it("ignores differences in independently configured environment settings", () => {
+ const target = {
+ environmentId: EnvironmentId.make("remote"),
+ label: "Remote",
+ settings: {
+ ...reference.settings,
+ newWorktreesStartFromOrigin: true,
+ continueThreadsAfterServerUpdate: true,
+ sourceControlWritingStyle: {
+ ...reference.settings.sourceControlWritingStyle,
+ customInstructions: "Keep this environment's writing instructions.",
+ },
+ },
+ };
+
+ const plan = planAutoSettleSettingsSync(reference, [target]);
+
+ expect(plan.mismatches).toEqual([]);
+ expect(plan.patch).toEqual({
+ sidebarAutoSettleAfterDays: 7,
+ sidebarAutoSettleOnMerge: true,
+ });
+ });
+
+ it("applies only auto-settle defaults when another environment differs", () => {
+ const target = {
+ environmentId: EnvironmentId.make("remote"),
+ label: "Remote",
+ settings: {
+ ...reference.settings,
+ sidebarAutoSettleAfterDays: null,
+ sidebarAutoSettleOnMerge: false,
+ newWorktreesStartFromOrigin: true,
+ continueThreadsAfterServerUpdate: true,
+ sourceControlWritingStyle: {
+ ...reference.settings.sourceControlWritingStyle,
+ customInstructions: "Preserve these instructions.",
+ },
+ },
+ };
+
+ const plan = planAutoSettleSettingsSync(reference, [target]);
+ const updated = { ...target.settings, ...plan.patch };
+
+ expect(plan.mismatches).toEqual([target]);
+ expect(updated.sidebarAutoSettleAfterDays).toBe(7);
+ expect(updated.sidebarAutoSettleOnMerge).toBe(true);
+ expect(updated.newWorktreesStartFromOrigin).toBe(true);
+ expect(updated.continueThreadsAfterServerUpdate).toBe(true);
+ expect(updated.sourceControlWritingStyle).toEqual(target.settings.sourceControlWritingStyle);
+ });
+
+ it("does not compare the reference or a target without loaded settings", () => {
+ const plan = planAutoSettleSettingsSync(reference, [
+ { ...reference, label: "Reference" },
+ { environmentId: EnvironmentId.make("loading"), label: "Loading", settings: null },
+ ]);
+
+ expect(plan.mismatches).toEqual([]);
+ });
+});
diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts
new file mode 100644
index 000000000..6addfa381
--- /dev/null
+++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts
@@ -0,0 +1,31 @@
+import type { EnvironmentId, ServerSettings } from "@t3tools/contracts";
+
+export type AutoSettleSettings = Pick<
+ ServerSettings,
+ "sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge"
+>;
+
+interface AutoSettleSyncTarget {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly settings: AutoSettleSettings | null;
+}
+
+/** Receives connected, capable targets. Applying these defaults must preserve other settings. */
+export function planAutoSettleSettingsSync(
+ reference: { readonly environmentId: EnvironmentId; readonly settings: AutoSettleSettings },
+ targets: readonly AutoSettleSyncTarget[],
+) {
+ const patch: AutoSettleSettings = {
+ sidebarAutoSettleAfterDays: reference.settings.sidebarAutoSettleAfterDays,
+ sidebarAutoSettleOnMerge: reference.settings.sidebarAutoSettleOnMerge,
+ };
+ const mismatches = targets.filter(
+ (target) =>
+ target.environmentId !== reference.environmentId &&
+ target.settings !== null &&
+ (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays ||
+ target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge),
+ );
+ return { patch, mismatches };
+}
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index e86e25cc7..0a2eff581 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -14,10 +14,15 @@ import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
+ DEFAULT_SERVER_SETTINGS,
MessageId,
T3_PROJECT_FILE_NAME,
ThreadId,
} from "@t3tools/contracts";
+import {
+ projectDefaultModelPreference,
+ resolveProjectSettings,
+} from "@t3tools/shared/projectSettings";
import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
import {
isDefaultThreadEnvModeSettled,
@@ -429,17 +434,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
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 projectSettings = useMemo(
+ () =>
+ resolveProjectSettings(
+ selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
+ selectedProject?.id ?? null,
+ selectedProject,
+ ),
+ [selectedEnvironmentServerConfig?.settings, selectedProject],
+ );
+ const projectThreadEnvMode =
+ projectSettings.sources.defaultThreadEnvMode === "project"
+ ? projectSettings.settings.defaultThreadEnvMode
+ : undefined;
const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({
- projectSetting: selectedProject?.defaultThreadEnvMode,
+ projectSetting: projectThreadEnvMode,
projectFile: t3ProjectFileDefaultMode,
- globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local",
+ 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: selectedProject?.defaultThreadEnvMode,
+ projectSetting: projectThreadEnvMode,
projectFilePending: t3ProjectFileQuery.isPending,
});
const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
@@ -450,10 +470,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
// value keeps tracking the server setting when the config loads late.
const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;
const startFromOrigin =
- draftStartFromOrigin ??
- selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
- true;
- const draftRuntimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
+ draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin;
+ const defaultRuntimeMode = editingPendingTask
+ ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE)
+ : projectSettings.settings.defaultRuntimeMode;
+ const draftRuntimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode;
const draftInteractionMode =
selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
@@ -463,10 +484,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
// server status for remediation and require a new pick instead of silently
// switching providers. Project and sticky defaults also reject legacy models.
const storedDraftModelSelection = selectedProjectDraft.modelSelection ?? null;
- const storedProjectDefaultModelSelection =
- selectedProject?.defaultModelSelection ??
- selectedEnvironmentServerConfig?.settings.defaultModelSelection ??
- null;
+ const storedProjectDefaultModelSelection = projectDefaultModelPreference(projectSettings);
const storedStickyModelSelection = useStickyComposerModelSelection();
const unavailablePreferredProvider = resolveNewTaskUnavailableProvider(
selectedEnvironmentServerConfig,
@@ -998,7 +1016,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
runtimeMode: resolveModelSelectionRuntimeMode(
selectedEnvironmentServerConfig,
draftModelSelection,
- draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,
+ draft.runtimeMode ?? defaultRuntimeMode,
),
...(preservedDeliveryHold === undefined ? {} : { deliveryHold: preservedDeliveryHold }),
interactionMode:
@@ -1040,6 +1058,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
};
},
[
+ defaultRuntimeMode,
editingPendingProject,
editingPendingTask,
selectedEnvironmentServerConfig,
diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx
index 6244d5ff7..a515f2ddd 100644
--- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx
+++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx
@@ -67,7 +67,9 @@ const CHART_HEIGHT = 180;
export function UsageRouteScreen() {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
- const [tab, setTab] = useState("usage");
+ // Limits first: remaining quota and reset time are what most people open
+ // the screen for.
+ const [tab, setTab] = useState("limits");
const [windowSelection, setWindowSelection] = useState(() => ({
days: 30,
window: makeWindow(30),
diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts
index 23cbd4f90..59709baf5 100644
--- a/apps/server/integration/OrchestrationEngineHarness.integration.ts
+++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts
@@ -68,6 +68,7 @@ import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDelet
import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts";
import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";
+import * as ProjectSettingsReactor from "../src/orchestration/ProjectSettingsReactor.ts";
import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
import {
@@ -406,6 +407,12 @@ export const makeOrchestrationIntegrationHarness = (
requestSync: () => Effect.void,
}),
),
+ Layer.provideMerge(
+ Layer.succeed(ProjectSettingsReactor.ProjectSettingsReactor, {
+ start: () => Effect.void,
+ drain: Effect.void,
+ }),
+ ),
Layer.provideMerge(
Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
publishThread: () => Effect.void,
diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts
index 7a40e1e53..dc6c02397 100644
--- a/apps/server/src/device/DeviceMultiHost.test.ts
+++ b/apps/server/src/device/DeviceMultiHost.test.ts
@@ -1,5 +1,5 @@
import { expect, it } from "@effect/vitest";
-import { ThreadId } from "@t3tools/contracts";
+import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import * as Effect from "effect/Effect";
@@ -7,6 +7,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ServerSettingsService } from "../serverSettings.ts";
import { DeviceHostError, DeviceHost } from "./DeviceHost.ts";
import { makeWithHosts } from "./DeviceService.ts";
+import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
it.effect("keeps hosts independent when serials collide and another host fails", () =>
Effect.gen(function* () {
@@ -89,7 +90,17 @@ it.effect("keeps hosts independent when serials collide and another host fails",
expect(state.hostStatuses.offline?.status).toBe("failed");
const targeting = yield* service
.agentTarget({ threadId, hostId: "b", deviceId: "emulator-5554" })
- .pipe(Effect.forkChild);
+ .pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, {
+ environmentId: EnvironmentId.make("environment-device-hosts"),
+ threadId,
+ providerSessionId: "provider-device-hosts",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ capabilities: new Set(["device"]),
+ issuedAt: 1,
+ }),
+ Effect.forkChild,
+ );
yield* Deferred.await(writeStarted);
const replacing = yield* service
.withLifecycleLock(
diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts
index 18680e25f..2dcfb9507 100644
--- a/apps/server/src/device/DeviceService.test.ts
+++ b/apps/server/src/device/DeviceService.test.ts
@@ -2,7 +2,10 @@ import { describe, expect, it } from "@effect/vitest";
import {
DEFAULT_SERVER_SETTINGS,
DeviceId,
+ EnvironmentId,
LOCAL_DEVICE_HOST_ID,
+ ProjectId,
+ ProviderInstanceId,
ThreadId,
type DeviceServiceState,
} from "@t3tools/contracts";
@@ -15,6 +18,8 @@ import * as Stream from "effect/Stream";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ServerSettingsService } from "../serverSettings.ts";
import * as DeviceHost from "./DeviceHost.ts";
+import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts";
@@ -66,6 +71,7 @@ const fixture = Effect.fn("fixture")(function* (
const starts: string[] = [];
const agentStarts: string[] = [];
const agentStops: string[] = [];
+ const agentConfigs: string[] = [];
const requests: string[] = [];
let booted = false;
let shutDown = false;
@@ -109,7 +115,12 @@ const fixture = Effect.fn("fixture")(function* (
starts.push("stop");
}),
};
- const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe(
+ const service = yield* makeWithHosts(new Map([[host.id, host]]), undefined, (hostId) =>
+ Effect.sync(() => {
+ agentConfigs.push(hostId);
+ return "/test-agent-config.json";
+ }),
+ ).pipe(
Effect.provideService(DeviceHost.DeviceHost, host),
Effect.provideService(
ServerSettingsService,
@@ -185,10 +196,70 @@ const fixture = Effect.fn("fixture")(function* (
),
),
);
- return { service, starts, agentStarts, agentStops, requests, settings };
+ return { service, starts, agentStarts, agentStops, agentConfigs, requests, settings };
});
describe("device setup consent", () => {
+ it.effect("honors authenticated project device grants and keeps reverse transitions closed", () =>
+ Effect.gen(function* () {
+ const { service, settings, agentStarts, agentConfigs } = yield* fixture();
+ const projectId = ProjectId.make("project-device-permission");
+ const threadId = ThreadId.make("thread-device-permission");
+ const input = { threadId, hostId: LOCAL_DEVICE_HOST_ID, deviceId: "Pixel_API_35" };
+ const invocation = (capabilities: ReadonlyArray) => ({
+ environmentId: EnvironmentId.make("environment-device-permission"),
+ threadId,
+ providerSessionId: "provider-device-permission",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ capabilities: new Set(capabilities),
+ issuedAt: 1,
+ });
+ const targetWith = (scope: McpInvocationContext.McpInvocationScope) =>
+ service
+ .agentTarget(input)
+ .pipe(Effect.provideService(McpInvocationContext.McpInvocationContext, scope));
+ yield* service.configure({ enabled: true });
+ yield* Ref.update(settings, (value) => ({
+ ...value,
+ enableAgentDeviceAccess: false,
+ projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } },
+ }));
+ const allowed = resolveProjectSettings(yield* Ref.get(settings), projectId).settings;
+ expect(allowed.enableAgentDeviceAccess).toBe(true);
+ expect(yield* service.agentReadinessIfSupported()).toBeNull();
+ const target = yield* targetWith(
+ invocation(allowed.enableAgentDeviceAccess ? ["device"] : []),
+ );
+ expect(target).toContain("/test-agent-config.json");
+ expect(agentStarts).toEqual(["start"]);
+ expect(agentConfigs).toEqual([LOCAL_DEVICE_HOST_ID]);
+
+ yield* Ref.update(settings, (value) => ({
+ ...value,
+ enableAgentDeviceAccess: true,
+ projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: false } },
+ }));
+ const denied = resolveProjectSettings(yield* Ref.get(settings), projectId).settings;
+ expect(denied.enableAgentDeviceAccess).toBe(false);
+ expect(
+ (yield* targetWith(invocation(denied.enableAgentDeviceAccess ? ["device"] : [])).pipe(
+ Effect.result,
+ ))._tag,
+ ).toBe("Failure");
+ expect((yield* targetWith(invocation(["preview"])).pipe(Effect.result))._tag).toBe("Failure");
+ expect(
+ (yield* targetWith({
+ ...invocation(["device"]),
+ threadId: ThreadId.make("another-thread"),
+ }).pipe(Effect.result))._tag,
+ ).toBe("Failure");
+ yield* service.configure({ enabled: false });
+ expect((yield* targetWith(invocation(["device"])).pipe(Effect.result))._tag).toBe("Failure");
+ expect(agentStarts).toEqual(["start"]);
+ expect(agentConfigs).toEqual([LOCAL_DEVICE_HOST_ID]);
+ }).pipe(Effect.scoped),
+ );
+
it.effect("listing and provider startup do not start helpers before consent", () =>
Effect.gen(function* () {
const { service, starts, requests } = yield* fixture();
diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts
index 400047815..56c295098 100644
--- a/apps/server/src/device/DeviceService.ts
+++ b/apps/server/src/device/DeviceService.ts
@@ -58,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
import * as ServerSettings from "../serverSettings.ts";
+import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
import * as ProcessRunner from "../processRunner.ts";
@@ -116,7 +117,11 @@ export class DeviceService extends Context.Service<
threadId: ThreadId;
hostId: DeviceHostId;
deviceId: DeviceId;
- }) => Effect.Effect, DeviceError>;
+ }) => Effect.Effect<
+ ReadonlyArray,
+ DeviceError,
+ McpInvocationContext.McpInvocationContext
+ >;
readonly state: Effect.Effect;
readonly subscribe: Effect.Effect, never, Scope.Scope>;
readonly configure: (
@@ -289,29 +294,39 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
return yield* readiness(host.id);
});
- const agentReadinessIfSupported: DeviceService["Service"]["agentReadinessIfSupported"] =
- Effect.fn("DeviceService.agentReadinessIfSupported")(function* (hostId) {
- const deviceSettings = yield* readDeviceSettings;
- if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;
- const host = yield* resolveHost(hostId);
- const summary = yield* host.summary;
- if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
- return null;
- const ready = yield* host
- .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
- .pipe(
- Effect.tapError((error) =>
- setHostStatus(host.id, { status: "failed", detail: error.message }),
- ),
- Effect.mapError(
- (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
- ),
- );
- const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);
- yield* publish((state) => ({ ...state, hosts: hostSummaries }));
- yield* setHostStatus(host.id, { status: "ready" });
- return { hostId: host.id, ...ready };
- }, lifecycleLock.withPermit);
+ const prepareAgentReadiness = Effect.fn("DeviceService.prepareAgentReadiness")(function* (
+ hostId: DeviceHostId | undefined,
+ authorization: "environment" | "credential",
+ ) {
+ const deviceSettings = yield* readDeviceSettings;
+ if (
+ !deviceSettings.enabled ||
+ (authorization === "environment" && !deviceSettings.agentAccessEnabled)
+ )
+ return null;
+ const host = yield* resolveHost(hostId);
+ const summary = yield* host.summary;
+ if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
+ return null;
+ const ready = yield* host
+ .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
+ .pipe(
+ Effect.tapError((error) =>
+ setHostStatus(host.id, { status: "failed", detail: error.message }),
+ ),
+ Effect.mapError(
+ (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
+ ),
+ );
+ const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);
+ yield* publish((state) => ({ ...state, hosts: hostSummaries }));
+ yield* setHostStatus(host.id, { status: "ready" });
+ return { hostId: host.id, ...ready };
+ }, lifecycleLock.withPermit);
+
+ const agentReadinessIfSupported: DeviceService["Service"]["agentReadinessIfSupported"] = (
+ hostId,
+ ) => prepareAgentReadiness(hostId, "environment");
const currentReadiness: DeviceService["Service"]["currentReadiness"] = (hostId) =>
resolveHost(hostId).pipe(
@@ -804,8 +819,25 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
),
agentTarget: (input) =>
Effect.gen(function* () {
+ // The authenticated credential already resolves this thread's project
+ // override. Rechecking the environment default would reject an allowed
+ // project; unrelated or unprivileged invocations must never mint a CLI target.
+ const invocation = yield* McpInvocationContext.requireMcpCapability("device").pipe(
+ Effect.mapError(
+ () =>
+ new DeviceHostUnavailableError({
+ hostId: input.hostId,
+ reason: "The authenticated agent session does not grant device access.",
+ }),
+ ),
+ );
+ if (invocation.threadId !== input.threadId)
+ return yield* new DeviceHostUnavailableError({
+ hostId: input.hostId,
+ reason: "The authenticated agent session belongs to another thread.",
+ });
const host = yield* resolveHost(input.hostId);
- const ready = yield* agentReadinessIfSupported(input.hostId);
+ const ready = yield* prepareAgentReadiness(input.hostId, "credential");
if (!ready)
return yield* new DeviceHostUnavailableError({
hostId: input.hostId,
@@ -814,6 +846,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
});
const configPath = yield* lifecycleLock.withPermit(
Effect.gen(function* () {
+ if (!(yield* readDeviceSettings).enabled)
+ return yield* new DeviceHostUnavailableError({
+ hostId: input.hostId,
+ reason: "Device support was disabled. Enable it before opening an agent target.",
+ });
if (hosts.get(host.id) !== host)
return yield* new DeviceHostUnavailableError({
hostId: host.id,
diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts
index 7e32137af..83677e417 100644
--- a/apps/server/src/environment/ServerEnvironment.test.ts
+++ b/apps/server/src/environment/ServerEnvironment.test.ts
@@ -170,6 +170,8 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.usagePriceOverrides).toBe(true);
expect(second.capabilities.browserProfiles).toBe(true);
expect(second.capabilities.projectDefaults).toBe(true);
+ expect(second.capabilities.projectSettingsOverrides).toBe(true);
+ expect(second.capabilities.defaultRuntimeMode).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
expect(second.capabilities.threadPullRequests).toBe(true);
expect(second.capabilities.threadPullRequestLinking).toBe(true);
diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
index f48bf8a81..608ba770a 100644
--- a/apps/server/src/environment/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -222,6 +222,8 @@ export const make = Effect.gen(function* () {
threadSettlement: true,
threadAutoSettlement: true,
threadRestartContinuation: true,
+ projectSettingsOverrides: true,
+ defaultRuntimeMode: true,
threadSnooze: true,
environmentThemes: true,
usageLimitSources: true,
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
index 75097815d..8bec757d1 100644
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -29,9 +29,16 @@ import {
type VcsStatusRemoteResult,
VcsStatusResult,
ModelSelection,
+ type ProjectId,
SourceControlProviderError,
type SourceControlWritingStyleSettings,
+ type ThreadId,
} from "@t3tools/contracts";
+import {
+ hasProjectSettingsOverrides,
+ resolveProjectSettings,
+} from "@t3tools/shared/projectSettings";
+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import {
detectSourceControlProviderFromGitRemoteUrl,
mergeGitStatusParts,
@@ -661,6 +668,28 @@ export const make = Effect.gen(function* () {
const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
const serverSettingsService = yield* ServerSettings.ServerSettingsService;
+ // Optional: git actions also run from the CLI and tests without orchestration.
+ const projectionQuery = yield* Effect.serviceOption(
+ ProjectionSnapshotQuery.ProjectionSnapshotQuery,
+ );
+ /** Environment settings with the acting project's overrides applied. */
+ const projectSettingsFor = Effect.fnUntraced(function* (input: {
+ readonly cwd: string;
+ readonly threadId?: ThreadId | undefined;
+ }) {
+ const settings = yield* serverSettingsService.getSettings;
+ if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings;
+ const projectId = yield* (
+ input.threadId !== undefined
+ ? projectionQuery.value
+ .getThreadShellById(input.threadId)
+ .pipe(Effect.map(Option.map((thread) => thread.projectId)))
+ : projectionQuery.value
+ .getActiveProjectByWorkspaceRoot(input.cwd)
+ .pipe(Effect.map(Option.map((project) => project.id)))
+ ).pipe(Effect.orElseSucceed(() => Option.none()));
+ return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings;
+ });
const readRepositoryInstructions = (cwd: string, fileName: string) =>
Effect.gen(function* () {
const root = yield* fileSystem.realPath(cwd);
@@ -2647,7 +2676,7 @@ export const make = Effect.gen(function* () {
let commitMessageForStep = input.commitMessage;
let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
- const textGenerationSettings = yield* serverSettingsService.getSettings.pipe(
+ const textGenerationSettings = yield* projectSettingsFor(input).pipe(
Effect.flatMap((settings) =>
settings.sourceControlWriterModelSelection === null
? Effect.succeed({
diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
index 5fcc32a34..e89b1a0a4 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
@@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge
import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
+import * as ProjectSettingsReactor from "../ProjectSettingsReactor.ts";
import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
@@ -67,6 +68,15 @@ describe("OrchestrationReactor", () => {
drainThrough: () => Effect.void,
}),
),
+ Layer.provideMerge(
+ Layer.succeed(ProjectSettingsReactor.ProjectSettingsReactor, {
+ start: () =>
+ Effect.sync(() => {
+ started.push("project-settings-reactor");
+ }),
+ drain: Effect.void,
+ }),
+ ),
Layer.provideMerge(
Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, {
start: () => {
@@ -117,6 +127,7 @@ describe("OrchestrationReactor", () => {
"checkpoint-reactor",
"thread-deletion-reactor",
"thread-pull-request-reactor",
+ "project-settings-reactor",
"thread-settlement-reactor",
"pull-request-sync-reactor",
"agent-awareness-relay",
diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts
index ff632240d..97618a04e 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts
@@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge
import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
+import * as ProjectSettingsReactor from "../ProjectSettingsReactor.ts";
import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
@@ -22,6 +23,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;
const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
+ const projectSettingsReactor = yield* ProjectSettingsReactor.ProjectSettingsReactor;
const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
@@ -30,6 +32,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
yield* checkpointReactor.start();
yield* threadDeletionReactor.start();
yield* threadPullRequestReactor.start();
+ yield* projectSettingsReactor.start();
yield* threadSettlementReactor.start();
yield* pullRequestSyncReactor.start();
yield* agentAwarenessRelay.start();
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
index b7d7a9896..edfb9eff1 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
@@ -721,6 +721,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
if (context._tag === "Some") {
assert.deepEqual(context.value, {
id: ThreadId.make("thread-1"),
+ projectId: asProjectId("project-1"),
title: "Thread 1",
session: snapshot.threads[0]?.session,
});
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
index 5e1028e40..f16d578af 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -165,6 +165,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({
});
const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
id: ThreadId,
+ projectId: ProjectId,
title: Schema.String,
session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),
});
@@ -1491,6 +1492,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
sql`
SELECT
threads.thread_id AS id,
+ threads.project_id AS "projectId",
threads.title,
sessions.thread_id AS "threadId",
sessions.status,
@@ -1530,6 +1532,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
Effect.map((rows) =>
rows.map((row) => ({
id: row.id,
+ projectId: row.projectId,
title: row.title,
session: row.threadId === null ? null : row,
})),
@@ -3641,6 +3644,7 @@ pending_approval_requests AS (
);
return Option.map(context, (row) => ({
id: row.id,
+ projectId: row.projectId,
title: row.title,
session: row.session === null ? null : mapSessionRow(row.session),
}));
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
index e51e83b84..8efe83312 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -62,6 +62,7 @@ import {
resolveSourceControlWriterModelSelection,
ServerSettingsService,
} from "../../serverSettings.ts";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
@@ -364,6 +365,16 @@ const make = Effect.gen(function* () {
const textGeneration = yield* TextGeneration;
const serverSettingsService = yield* ServerSettingsService;
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
+ /** Environment settings with the thread's project overrides applied. */
+ const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) {
+ const settings = yield* serverSettingsService.getSettings;
+ if (Object.keys(settings.projectSettingsOverrides).length === 0) return settings;
+ const thread = yield* projectionSnapshotQuery
+ .getThreadShellById(threadId)
+ .pipe(Effect.orElseSucceed(() => Option.none()));
+ return resolveProjectSettings(settings, Option.isSome(thread) ? thread.value.projectId : null)
+ .settings;
+ });
const serverCommandId = (tag: string) =>
crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)));
const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make));
@@ -1341,7 +1352,7 @@ const make = Effect.gen(function* () {
const cwd = input.worktreePath;
const attachments = input.attachments ?? [];
yield* Effect.gen(function* () {
- const settings = yield* serverSettingsService.getSettings;
+ const settings = yield* projectSettingsForThread(input.threadId);
const modelSelection =
settings.sourceControlWriterModelSelection === null
? settings.textGenerationModelSelection
@@ -1392,8 +1403,9 @@ const make = Effect.gen(function* () {
}) {
const attachments = input.attachments ?? [];
yield* Effect.gen(function* () {
- const { textGenerationModelSelection: modelSelection } =
- yield* serverSettingsService.getSettings;
+ const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread(
+ input.threadId,
+ );
const generated = yield* textGeneration
.generateThreadTitle({
@@ -1462,8 +1474,10 @@ const make = Effect.gen(function* () {
thread,
projects: project ? [project] : [],
}) ?? process.cwd();
- const { textGenerationModelSelection: modelSelection } =
- yield* serverSettingsService.getSettings;
+ const { textGenerationModelSelection: modelSelection } = resolveProjectSettings(
+ yield* serverSettingsService.getSettings,
+ thread.projectId,
+ ).settings;
const generated = yield* textGeneration.generateThreadTitle({
cwd,
message,
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
index 12072be71..0e90e4f83 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
@@ -66,6 +66,7 @@ import {
import { projectActivityPayload } from "../ActivityPayloadProjection.ts";
import { forkParked } from "../../serverActivation.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { canReplaceThreadTitle } from "../threadTitles.ts";
class PrivateProviderRuntimeEventFence extends Context.Service<
@@ -2710,7 +2711,10 @@ const make = Effect.gen(function* () {
const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
serverSettingsService.getSettings,
- (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),
+ (settings) =>
+ resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
+ ? "streaming"
+ : "buffered",
);
if (assistantDeliveryMode === "buffered") {
const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
@@ -2752,7 +2756,10 @@ const make = Effect.gen(function* () {
});
const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
serverSettingsService.getSettings,
- (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),
+ (settings) =>
+ resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
+ ? "streaming"
+ : "buffered",
);
const flushedMessageIds =
assistantDeliveryMode === "buffered"
diff --git a/apps/server/src/orchestration/ProjectSettingsReactor.test.ts b/apps/server/src/orchestration/ProjectSettingsReactor.test.ts
new file mode 100644
index 000000000..072b4a16c
--- /dev/null
+++ b/apps/server/src/orchestration/ProjectSettingsReactor.test.ts
@@ -0,0 +1,74 @@
+import {
+ DEFAULT_SERVER_SETTINGS,
+ EventId,
+ ProjectId,
+ type OrchestrationEvent,
+} from "@t3tools/contracts";
+import { assert, it } from "@effect/vitest";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as PubSub from "effect/PubSub";
+import * as Queue from "effect/Queue";
+import * as Stream from "effect/Stream";
+import { ServerActivation } from "../serverActivation.ts";
+import { ServerSettingsService } from "../serverSettings.ts";
+import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
+import * as ProjectSettingsReactor from "./ProjectSettingsReactor.ts";
+
+it.effect("buffers legacy edits across activation and drains only meaningful settings events", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const events = yield* PubSub.unbounded();
+ const activation = yield* Deferred.make();
+ const receipts = yield* Queue.unbounded();
+ let updates = 0;
+ const dependencies = Layer.mergeAll(
+ Layer.mock(OrchestrationEngineService)({
+ subscribeDomainEvents: PubSub.subscribe(events).pipe(Effect.map(Stream.fromSubscription)),
+ }),
+ Layer.mock(ServerSettingsService)({
+ updateSettings: () =>
+ Effect.sync(() => {
+ updates += 1;
+ }).pipe(
+ Effect.andThen(Queue.offer(receipts, undefined)),
+ Effect.as(DEFAULT_SERVER_SETTINGS),
+ ),
+ }),
+ Layer.succeed(ServerActivation, Deferred.await(activation)),
+ );
+ yield* Effect.gen(function* () {
+ const reactor = yield* ProjectSettingsReactor.ProjectSettingsReactor;
+ yield* reactor.start();
+ const base = {
+ eventId: EventId.make("legacy-edit"),
+ aggregateKind: "project" as const,
+ aggregateId: ProjectId.make("project"),
+ occurredAt: "2026-09-12T00:00:00.000Z",
+ commandId: null,
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ };
+ yield* PubSub.publish(events, {
+ ...base,
+ sequence: 1,
+ type: "project.meta-updated",
+ payload: { projectId: base.aggregateId, title: "Renamed", updatedAt: base.occurredAt },
+ });
+ yield* PubSub.publish(events, {
+ ...base,
+ sequence: 2,
+ type: "project.meta-updated",
+ payload: { projectId: base.aggregateId, scripts: [], updatedAt: base.occurredAt },
+ });
+ assert.equal(updates, 0);
+ yield* Deferred.succeed(activation, undefined);
+ yield* Queue.take(receipts);
+ yield* reactor.drain;
+ assert.equal(updates, 1);
+ }).pipe(Effect.provide(ProjectSettingsReactor.layer.pipe(Layer.provide(dependencies))));
+ }),
+ ),
+);
diff --git a/apps/server/src/orchestration/ProjectSettingsReactor.ts b/apps/server/src/orchestration/ProjectSettingsReactor.ts
new file mode 100644
index 000000000..82b298d22
--- /dev/null
+++ b/apps/server/src/orchestration/ProjectSettingsReactor.ts
@@ -0,0 +1,58 @@
+import type { OrchestrationEvent } from "@t3tools/contracts";
+import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
+import * as Cause from "effect/Cause";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import type * as Scope from "effect/Scope";
+import * as Stream from "effect/Stream";
+import { forkParked } from "../serverActivation.ts";
+import { ServerSettingsService } from "../serverSettings.ts";
+import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
+
+export class ProjectSettingsReactor extends Context.Service<
+ ProjectSettingsReactor,
+ {
+ readonly start: () => Effect.Effect;
+ readonly drain: Effect.Effect;
+ }
+>()("t3/orchestration/ProjectSettingsReactor") {}
+
+/** Old clients write the project aggregate; the settings service replays its durable journal. */
+const make = Effect.gen(function* () {
+ const engine = yield* OrchestrationEngineService;
+ const settings = yield* ServerSettingsService;
+ const worker = yield* makeDrainableWorker((_event: OrchestrationEvent) =>
+ settings.updateSettings({}).pipe(
+ Effect.asVoid,
+ Effect.catchCause((cause) =>
+ Cause.hasInterruptsOnly(cause)
+ ? Effect.failCause(cause)
+ : Effect.logWarning("legacy project settings synchronization failed", {
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ ),
+ );
+ const start = Effect.fn("ProjectSettingsReactor.start")(function* () {
+ const events = yield* engine.subscribeDomainEvents;
+ yield* forkParked(
+ Stream.runForEach(events, (event) => {
+ if (
+ event.type === "project.created" ||
+ (event.type === "project.meta-updated" &&
+ (event.payload.defaultModelSelection !== undefined ||
+ event.payload.defaultThreadEnvMode !== undefined ||
+ event.payload.autoPull !== undefined ||
+ event.payload.scripts !== undefined))
+ ) {
+ return worker.enqueue(event);
+ }
+ return Effect.void;
+ }),
+ );
+ });
+ return { start, drain: worker.drain } satisfies ProjectSettingsReactor["Service"];
+});
+
+export const layer = Layer.effect(ProjectSettingsReactor, make);
diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
index 8e392af31..ca48c64b4 100644
--- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
@@ -234,7 +234,7 @@ export interface ProjectionSnapshotQueryShape {
readonly getThreadRuntimeContext: (
threadId: ThreadId,
) => Effect.Effect<
- Option.Option>,
+ Option.Option>,
ProjectionRepositoryError
>;
diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts
index 14453a2b9..455cb37a6 100644
--- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts
+++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts
@@ -356,6 +356,36 @@ describe("ThreadSettlementReactor", () => {
}),
),
);
+
+ it("distinguishes a project that inherits the threshold from one that disables it", () => {
+ const inherits = ThreadSettlementReactor.autoSettlementSettingsKey({
+ ...DEFAULT_SERVER_SETTINGS,
+ projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } },
+ });
+ const never = ThreadSettlementReactor.autoSettlementSettingsKey({
+ ...DEFAULT_SERVER_SETTINGS,
+ projectSettingsOverrides: {
+ [PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null },
+ },
+ });
+ assert.notStrictEqual(inherits, never);
+ });
+
+ it("ignores project overrides that do not touch settlement", () => {
+ const base = ThreadSettlementReactor.autoSettlementSettingsKey({
+ ...DEFAULT_SERVER_SETTINGS,
+ projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } },
+ });
+ const unrelated = ThreadSettlementReactor.autoSettlementSettingsKey({
+ ...DEFAULT_SERVER_SETTINGS,
+ projectSettingsOverrides: {
+ [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" },
+ [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true },
+ },
+ });
+ assert.strictEqual(base, unrelated);
+ });
+
it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -489,6 +519,59 @@ describe("ThreadSettlementReactor", () => {
),
);
+ it.effect("a project override settles only that project's inactive threads", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ yield* TestClock.setTime(Date.parse(NOW));
+ const overriddenProject = ProjectId.make("overridden-project");
+ const fixture = yield* makeHarness({
+ snapshot: makeSnapshot(
+ [
+ makeThread("inherits-thread"),
+ makeThread("overridden-thread", { projectId: overriddenProject }),
+ ],
+ [makeProject(), makeProject(overriddenProject, "/workspace/overridden")],
+ ),
+ settings: {
+ ...DEFAULT_SERVER_SETTINGS,
+ sidebarAutoSettleAfterDays: null,
+ sidebarAutoSettleOnMerge: false,
+ projectSettingsOverrides: {
+ [overriddenProject]: { sidebarAutoSettleAfterDays: 1 },
+ },
+ },
+ });
+
+ yield* Effect.gen(function* () {
+ const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
+ yield* reactor.start();
+ yield* Queue.take(fixture.settingsReads);
+ yield* Deferred.succeed(fixture.activation, undefined);
+ yield* Queue.take(fixture.snapshotReads);
+ yield* reactor.drain;
+ assert.deepStrictEqual(
+ (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
+ [ThreadId.make("overridden-thread")],
+ );
+
+ // Clearing the override is a settlement change, so the sweep re-arms.
+ yield* fixture.updateSettings({
+ projectSettingsOverrides: { [overriddenProject]: null },
+ sidebarAutoSettleAfterDays: 1,
+ });
+ yield* Queue.take(fixture.snapshotReads);
+ yield* reactor.drain;
+ // The static snapshot never records the first settlement, so the
+ // second sweep dispatches for both; the inheriting thread is new.
+ assert.include(
+ (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
+ ThreadId.make("inherits-thread"),
+ );
+ }).pipe(Effect.provide(fixture.layer));
+ }),
+ ),
+ );
+
it.effect("starts without clients and skips protected threads before pull request lookup", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts
index 61fc5d4ab..b9041d297 100644
--- a/apps/server/src/orchestration/ThreadSettlementReactor.ts
+++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts
@@ -1,4 +1,5 @@
-import { CommandId } from "@t3tools/contracts";
+import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
@@ -32,6 +33,45 @@ export class ThreadSettlementReactor extends Context.Service<
}
>()("t3/orchestration/ThreadSettlementReactor") {}
+/** @public Service construction is part of the canonical Effect module API. */
+/** Whether any environment default or project override can settle a thread. */
+function autoSettlementConfigured(settings: ServerSettingsValue): boolean {
+ if (settings.sidebarAutoSettleOnMerge || settings.sidebarAutoSettleAfterDays !== null) {
+ return true;
+ }
+ return Object.values(settings.projectSettingsOverrides).some(
+ (entry) =>
+ entry.sidebarAutoSettleOnMerge === true ||
+ (entry.sidebarAutoSettleAfterDays !== undefined && entry.sidebarAutoSettleAfterDays !== null),
+ );
+}
+
+/** Identity of every settlement input, so unrelated settings edits do not trigger a sweep. */
+/** @internal Exported for tests. */
+export function autoSettlementSettingsKey(settings: ServerSettingsValue): string {
+ return JSON.stringify([
+ settings.sidebarAutoSettleOnMerge,
+ settings.sidebarAutoSettleAfterDays,
+ // Only entries that touch settlement, in a stable order, so a project
+ // override on an unrelated key does not queue a sweep. JSON drops
+ // undefined, so inherit (absent) and never (null) need distinct marks.
+ Object.entries(settings.projectSettingsOverrides)
+ .filter(
+ ([, entry]) =>
+ entry.sidebarAutoSettleOnMerge !== undefined ||
+ entry.sidebarAutoSettleAfterDays !== undefined,
+ )
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([projectId, entry]) => [
+ projectId,
+ entry.sidebarAutoSettleOnMerge ?? "inherit",
+ entry.sidebarAutoSettleAfterDays === undefined
+ ? "inherit"
+ : entry.sidebarAutoSettleAfterDays,
+ ]),
+ ]);
+}
+
/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const engine = yield* OrchestrationEngine.OrchestrationEngineService;
@@ -46,7 +86,7 @@ export const make = Effect.gen(function* () {
mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
) {
const settings = yield* settingsService.getSettings;
- if (!settings.sidebarAutoSettleOnMerge && settings.sidebarAutoSettleAfterDays === null) {
+ if (!autoSettlementConfigured(settings)) {
return;
}
const snapshot = yield* snapshots.getShellSnapshot();
@@ -60,7 +100,10 @@ export const make = Effect.gen(function* () {
// dispatch skips it for this snapshot instead of retrying through a lookup.
const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")(
function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {
- const settings = yield* settingsService.getSettings;
+ const settings = resolveProjectSettings(
+ yield* settingsService.getSettings,
+ thread.projectId,
+ ).settings;
const decisionNow = DateTime.formatIso(yield* DateTime.now);
const settledAt = resolveAutoSettlementAt({
thread,
@@ -254,8 +297,7 @@ export const make = Effect.gen(function* () {
const settingsChanges = yield* settingsService.subscribeChanges;
const mergedPullRequests = yield* pullRequests.subscribeMerges;
const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
- let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
- let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
+ let lastSettlementSettings = autoSettlementSettingsKey(initialSettings);
yield* forkParked(
Effect.gen(function* () {
yield* worker.enqueue(undefined);
@@ -264,14 +306,11 @@ export const make = Effect.gen(function* () {
);
yield* forkParked(
Stream.runForEach(settingsChanges, (settings) => {
- if (
- settings.sidebarAutoSettleAfterDays === lastAfterDays &&
- settings.sidebarAutoSettleOnMerge === lastOnMerge
- ) {
+ const key = autoSettlementSettingsKey(settings);
+ if (key === lastSettlementSettings) {
return Effect.void;
}
- lastAfterDays = settings.sidebarAutoSettleAfterDays;
- lastOnMerge = settings.sidebarAutoSettleOnMerge;
+ lastSettlementSettings = key;
return worker.enqueue(undefined);
}),
);
diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts
index 6a4ecbd67..a9b652a9f 100644
--- a/apps/server/src/provider/Layers/CodexProvider.ts
+++ b/apps/server/src/provider/Layers/CodexProvider.ts
@@ -157,7 +157,8 @@ export function mapCodexModelCapabilities(
model: CodexSchema.V2ModelListResponse__Model,
): ModelCapabilities {
const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) =>
- reasoningEffort === model.defaultReasoningEffort
+ reasoningEffort ===
+ (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort)
? {
id: reasoningEffort,
label: reasoningEffortLabel(reasoningEffort),
diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts
index 279ac2443..f8503203f 100644
--- a/apps/server/src/provider/Layers/ProviderService.test.ts
+++ b/apps/server/src/provider/Layers/ProviderService.test.ts
@@ -83,6 +83,7 @@ import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts";
+import { PersistenceSqlError } from "../../persistence/Errors.ts";
import { RollbackSagaRepositoryLive } from "../../persistence/Layers/RollbackSagas.ts";
import { RollbackSagaRepository } from "../../persistence/Services/RollbackSagas.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
@@ -730,16 +731,83 @@ function makeProviderServiceLayer(
};
}
-for (const [enabled, completed, retainedDaemon] of [
- [false, false, false],
- [true, false, false],
- [true, true, false],
- [false, false, true],
- [true, false, true],
- [true, true, true],
+const decodeProjectSettingsThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell);
+const makeThreadProjectProjectionLayer = (
+ threadId: ThreadId,
+ projectId: ProjectId,
+ projectionStatus: () => "found" | "missing" | "failed" = () => "found",
+) =>
+ Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
+ getPendingRequestActivities: () => Effect.die("unused"),
+ getUserInputActivity: () => Effect.die("unused"),
+ getCommandReadModel: () => Effect.die("unused"),
+ getSnapshot: () => Effect.die("unused"),
+ getShellSnapshot: () => Effect.die("unused"),
+ getArchivedShellSnapshot: () => Effect.die("unused"),
+ getSnapshotSequence: () => Effect.die("unused"),
+ getCounts: () => Effect.die("unused"),
+ getEventReplayStats: () => Effect.die("unused"),
+ getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
+ getProjectShellById: () => Effect.die("unused"),
+ getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
+ getThreadCheckpointContext: () => Effect.die("unused"),
+ getFullThreadDiffContext: () => Effect.die("unused"),
+ getThreadRuntimeContext: () => Effect.die("unused"),
+ getTurnStartMessage: () => Effect.die("unused"),
+ getThreadShellById: (requestedThreadId) =>
+ Effect.gen(function* () {
+ assert.equal(requestedThreadId, threadId);
+ const status = projectionStatus();
+ if (status === "missing") return Option.none();
+ if (status === "failed") {
+ return yield* new PersistenceSqlError({ operation: "get-thread-shell" });
+ }
+ return Option.some(
+ yield* decodeProjectSettingsThreadShell({
+ id: threadId,
+ projectId,
+ title: "Project settings test",
+ modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"),
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ latestTurn: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ session: null,
+ latestUserMessageAt: null,
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ hasActionableProposedPlan: false,
+ }).pipe(Effect.orDie),
+ );
+ }),
+ getThreadDetailById: () => Effect.die("unused"),
+ getThreadDetailSnapshot: () => Effect.die("unused"),
+ searchThreads: () => Effect.die("unused"),
+ });
+
+for (const [enabled, completed, retainedDaemon, projectOverride, projectionStatus = "found"] of [
+ [false, false, false, undefined],
+ [true, false, false, undefined],
+ [true, true, false, undefined],
+ [false, false, true, undefined],
+ [true, false, true, undefined],
+ [true, true, true, undefined],
+ [false, false, false, true],
+ [true, false, false, false],
+ [false, false, true, true],
+ [true, false, true, false],
+ [true, false, false, false, "missing"],
+ [true, false, false, false, "failed"],
+ [true, false, true, false, "missing"],
+ [true, false, true, false, "failed"],
+ [true, false, false, false, "unavailable"],
+ [true, false, true, false, "unavailable"],
] as const) {
it.effect(
- `persists shutdown recovery before stopping providers when enabled=${enabled}, completed=${completed}, retainedDaemon=${retainedDaemon}`,
+ `persists shutdown recovery before stopping providers when enabled=${enabled}, completed=${completed}, retainedDaemon=${retainedDaemon}, projectOverride=${projectOverride}, projection=${projectionStatus}`,
() =>
Effect.gen(function* () {
const codex = makeFakeCodexAdapter();
@@ -756,8 +824,10 @@ for (const [enabled, completed, retainedDaemon] of [
Effect.provide(persistence),
);
const threadId = asThreadId("shutdown-recovery");
+ const projectId = ProjectId.make("shutdown-project");
const turnId = asTurnId("shutdown-recovery-turn");
const scope = yield* Scope.make();
+ let stopping = false;
const services = yield* Layer.build(
makeProviderServiceLive().pipe(
Layer.provide(
@@ -772,7 +842,22 @@ for (const [enabled, completed, retainedDaemon] of [
}),
),
),
- Layer.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: enabled })),
+ Layer.provide(
+ ServerSettings.layerTest({
+ continueThreadsAfterServerUpdate: enabled,
+ projectSettingsOverrides:
+ projectOverride === undefined
+ ? {}
+ : { [projectId]: { continueThreadsAfterServerUpdate: projectOverride } },
+ }),
+ ),
+ Layer.provide(
+ projectOverride === undefined || projectionStatus === "unavailable"
+ ? Layer.empty
+ : makeThreadProjectProjectionLayer(threadId, projectId, () =>
+ stopping ? projectionStatus : "found",
+ ),
+ ),
Layer.provide(serverConfigTestLayer),
Layer.provide(AnalyticsService.layerTest),
Layer.provide(
@@ -828,6 +913,7 @@ for (const [enabled, completed, retainedDaemon] of [
markers.push(binding.value.runtimePayload);
}).pipe(Effect.orDie),
);
+ stopping = true;
yield* Scope.close(scope, Exit.void);
const binding = yield* directory.getBinding(threadId);
assert(Option.isSome(binding));
@@ -837,7 +923,11 @@ for (const [enabled, completed, retainedDaemon] of [
assert.deepStrictEqual(binding.value.resumeCursor, session.resumeCursor);
assert.equal(binding.value.status, "stopped");
assert.propertyVal(markers[0], "activeTurnId", completed ? null : turnId);
- if (enabled && !completed) {
+ const continuationEnabled =
+ projectionStatus === "unavailable"
+ ? enabled
+ : projectionStatus === "found" && (projectOverride ?? enabled);
+ if (continuationEnabled && !completed) {
assert.propertyVal(markers[0], "continueAfterServerUpdate", turnId);
assert.propertyVal(binding.value.runtimePayload, "continueAfterServerUpdate", turnId);
} else if (completed) {
@@ -6564,8 +6654,6 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => {
);
});
-const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell);
-
class RuntimeRollbackAdmission extends Context.Service()(
"t3/provider/Layers/ProviderService.test/RuntimeRollbackAdmission",
) {}
@@ -6579,52 +6667,10 @@ class RuntimeReaper extends Context.Service()(
describe("agent browser access", () => {
const projectId = ProjectId.make("project-browser-access");
- const makeBrowserAccessProjectionLayer = (threadId: ThreadId) =>
- Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
- getPendingRequestActivities: () => Effect.die("unused"),
- getUserInputActivity: () => Effect.die("unused"),
- getCommandReadModel: () => Effect.die("unused"),
- getSnapshot: () => Effect.die("unused"),
- getShellSnapshot: () => Effect.die("unused"),
- getArchivedShellSnapshot: () => Effect.die("unused"),
- getSnapshotSequence: () => Effect.die("unused"),
- getCounts: () => Effect.die("unused"),
- getEventReplayStats: () => Effect.die("unused"),
- getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
- getProjectShellById: () => Effect.die("unused"),
- getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
- getImportedAgentSessionSources: () => Effect.die("unused"),
- getThreadCheckpointContext: () => Effect.die("unused"),
- getFullThreadDiffContext: () => Effect.die("unused"),
- getThreadRuntimeContext: () => Effect.die("unused"),
- getTurnStartMessage: () => Effect.die("unused"),
- getThreadShellById: (requestedThreadId) =>
- Effect.gen(function* () {
- assert.equal(requestedThreadId, threadId);
- return Option.some(
- yield* decodeBrowserAccessThreadShell({
- id: threadId,
- projectId,
- title: "Browser access test",
- modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"),
- runtimeMode: "full-access",
- branch: null,
- worktreePath: null,
- latestTurn: null,
- createdAt: "2026-01-01T00:00:00.000Z",
- updatedAt: "2026-01-01T00:00:00.000Z",
- session: null,
- latestUserMessageAt: null,
- hasPendingApprovals: false,
- hasPendingUserInput: false,
- hasActionableProposedPlan: false,
- }),
- );
- }).pipe(Effect.orDie),
- getThreadDetailById: () => Effect.die("unused"),
- getThreadDetailSnapshot: () => Effect.die("unused"),
- searchThreads: () => Effect.die("unused"),
- });
+ const makeBrowserAccessProjectionLayer = (
+ threadId: ThreadId,
+ status: "found" | "missing" | "failed" = "found",
+ ) => makeThreadProjectProjectionLayer(threadId, projectId, () => status);
const makeAgentBrowserProviderLayer = (
enableAgentBrowserAccess: boolean,
@@ -6632,9 +6678,13 @@ describe("agent browser access", () => {
options: NonNullable[0]>,
project?: {
readonly threadId: ThreadId;
- readonly override?: boolean | undefined;
+ readonly override?:
+ | boolean
+ | { readonly browser?: boolean; readonly device?: boolean }
+ | undefined;
/** False leaves the projection query to the surrounding runtime composition. */
readonly provideProjection?: boolean;
+ readonly projectionStatus?: "found" | "missing" | "failed";
},
enableAgentDeviceAccess = false,
) => {
@@ -6652,15 +6702,29 @@ describe("agent browser access", () => {
Layer.provideMerge(directoryLayer),
Layer.provide(
project && project.provideProjection !== false
- ? makeBrowserAccessProjectionLayer(project.threadId)
+ ? makeBrowserAccessProjectionLayer(project.threadId, project.projectionStatus)
: Layer.empty,
),
Layer.provide(
ServerSettings.ServerSettingsService.layerTest({
enableAgentBrowserAccess,
enableAgentDeviceAccess,
- projectAgentBrowserAccessOverrides:
- projectOverride === undefined ? {} : { [projectId]: projectOverride },
+ projectSettingsOverrides:
+ projectOverride === undefined
+ ? {}
+ : {
+ [projectId]:
+ typeof projectOverride === "boolean"
+ ? { enableAgentBrowserAccess: projectOverride }
+ : {
+ ...(projectOverride.browser !== undefined
+ ? { enableAgentBrowserAccess: projectOverride.browser }
+ : {}),
+ ...(projectOverride.device !== undefined
+ ? { enableAgentDeviceAccess: projectOverride.device }
+ : {}),
+ },
+ },
}),
),
Layer.provide(serverConfigTestLayer),
@@ -6761,6 +6825,56 @@ describe("agent browser access", () => {
}).pipe(Effect.provide(NodeServices.layer)),
);
+ it.effect(
+ "resolves project device overrides and withholds only overridden unresolved capabilities",
+ () =>
+ Effect.gen(function* () {
+ for (const [
+ browser,
+ device,
+ override,
+ provideProjection,
+ expected,
+ projectionStatus = "found",
+ ] of [
+ [false, false, { device: true }, true, ["device"]],
+ [true, true, { device: false }, true, ["preview"]],
+ [true, true, { device: false }, false, ["preview"]],
+ [false, false, { device: true }, false, []],
+ [true, true, { device: false }, true, ["preview"], "failed"],
+ ] as const) {
+ const threadId = asThreadId(
+ `thread-project-device-${browser}-${device}-${provideProjection}`,
+ );
+ const issued: string[][] = [];
+ const codex = makeFakeCodexAdapter();
+ const layer = makeAgentBrowserProviderLayer(
+ browser,
+ codex,
+ {
+ issueMcpCredential: (request) =>
+ Effect.sync(() => {
+ issued.push([...request.capabilities].sort());
+ return undefined;
+ }),
+ },
+ { threadId, override, provideProjection, projectionStatus },
+ device,
+ );
+ yield* Effect.gen(function* () {
+ const provider = yield* ProviderService.ProviderService;
+ yield* provider.startSession(threadId, {
+ provider: CODEX_DRIVER,
+ providerInstanceId: codexInstanceId,
+ threadId,
+ runtimeMode: "full-access",
+ });
+ }).pipe(Effect.provide(layer));
+ assert.deepEqual(issued, [[...expected, "pull-requests"].sort()]);
+ }
+ }).pipe(Effect.provide(NodeServices.layer)),
+ );
+
it.effect("does not publish retired MCP ownership after device CLI preparation yields", () =>
Effect.gen(function* () {
const threadId = asThreadId("thread-device-generation-fence");
diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts
index 10fd745aa..63461f0b3 100644
--- a/apps/server/src/provider/Layers/ProviderService.ts
+++ b/apps/server/src/provider/Layers/ProviderService.ts
@@ -54,16 +54,18 @@ import {
ProviderSessionStartInput,
ProviderStopSessionInput,
ProviderUploadFeedbackInput,
+ type ProjectId,
type ProviderInstanceId,
type ProviderDriverKind,
type ProviderRuntimeEvent,
type ProviderSession,
+ type ServerSettings as ServerSettingsValue,
} from "@t3tools/contracts";
import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { causeErrorTag } from "@t3tools/shared/observability";
import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
-import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
@@ -1154,34 +1156,42 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
* "off" silently becoming "on" would violate the user's stated choice,
* whereas the reverse costs an agent one toolset and is visible immediately.
*/
- const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")(
+ const agentAccessSettings = Effect.fn("ProviderService.agentAccessSettings")(
function* (threadId: ThreadId) {
const settings = yield* serverSettings.getSettings;
- if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) {
- return settings.enableAgentBrowserAccess;
- }
+ const entries = Object.values(settings.projectSettingsOverrides);
+ const browserOverridden = entries.some(
+ (entry) => entry.enableAgentBrowserAccess !== undefined,
+ );
+ const deviceOverridden = entries.some((entry) => entry.enableAgentDeviceAccess !== undefined);
+ const environment = {
+ browser: settings.enableAgentBrowserAccess,
+ device: settings.enableAgentDeviceAccess,
+ };
+ if (!browserOverridden && !deviceOverridden) return environment;
// Provider-only runtimes may omit orchestration. An unresolved project
- // must not bypass an explicit browser override.
- if (Option.isNone(projectionQuery)) return false;
- const thread = yield* projectionQuery.value.getThreadShellById(threadId);
- if (Option.isNone(thread)) return false;
- return resolveProjectAgentBrowserAccess(settings, thread.value.projectId);
+ // must not bypass an explicit project override, but a capability no
+ // project overrides keeps its environment value.
+ const denied = {
+ browser: browserOverridden ? false : environment.browser,
+ device: deviceOverridden ? false : environment.device,
+ };
+ if (Option.isNone(projectionQuery)) return denied;
+ const thread = yield* projectionQuery.value
+ .getThreadShellById(threadId)
+ .pipe(Effect.orElseSucceed(() => Option.none()));
+ if (Option.isNone(thread)) return denied;
+ const resolved = resolveProjectSettings(settings, thread.value.projectId).settings;
+ return {
+ browser: resolved.enableAgentBrowserAccess,
+ device: resolved.enableAgentDeviceAccess,
+ };
},
Effect.catch((cause) =>
Effect.logWarning(
- "Could not read server settings; withholding agent browser access for this session.",
+ "Could not read server settings; withholding agent browser and device access for this session.",
{ cause },
- ).pipe(Effect.as(false)),
- ),
- );
-
- const agentDeviceAccessEnabled = serverSettings.getSettings.pipe(
- Effect.map((settings) => settings.enableAgentDeviceAccess),
- Effect.catch((cause) =>
- Effect.logWarning(
- "Could not read server settings; withholding agent device access for this session.",
- { cause },
- ).pipe(Effect.as(false)),
+ ).pipe(Effect.as({ browser: false, device: false })),
),
);
@@ -1189,8 +1199,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
threadId: ThreadId,
) {
const capabilities = new Set(["pull-requests"]);
- if (yield* agentBrowserAccessEnabled(threadId)) capabilities.add("preview");
- if (yield* agentDeviceAccessEnabled) capabilities.add("device");
+ const access = yield* agentAccessSettings(threadId);
+ if (access.browser) capabilities.add("preview");
+ if (access.device) capabilities.add("device");
return capabilities;
});
@@ -4196,11 +4207,36 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
},
);
- const runStopAll = Effect.fn("runStopAll")(function* () {
- const continueAfterRestart = yield* serverSettings.getSettings.pipe(
- Effect.map((settings) => settings.continueThreadsAfterServerUpdate),
- Effect.orElseSucceed(() => false),
+ // Snapshot settings once per stop operation, then resolve continuation against
+ // each session's project in both the ordinary and mixed-adapter shutdown paths.
+ const readStopSettings = serverSettings.getSettings.pipe(
+ Effect.map(Option.some),
+ Effect.orElseSucceed(() => Option.none()),
+ );
+ const continueAfterRestartFor = Effect.fn("continueAfterRestartFor")(function* (
+ stopSettings: Option.Option,
+ threadId: ThreadId,
+ ) {
+ if (Option.isNone(stopSettings)) return false;
+ const settings = stopSettings.value;
+ const overridden = Object.values(settings.projectSettingsOverrides).some(
+ (entry) => entry.continueThreadsAfterServerUpdate !== undefined,
);
+ if (!overridden || Option.isNone(projectionQuery)) {
+ return settings.continueThreadsAfterServerUpdate;
+ }
+ const thread = yield* projectionQuery.value
+ .getThreadShellById(threadId)
+ .pipe(Effect.orElseSucceed(() => Option.none<{ projectId: ProjectId }>()));
+ // With project overrides present, a missing or failed lookup cannot establish
+ // that continuation was allowed for this session.
+ if (Option.isNone(thread)) return false;
+ return resolveProjectSettings(settings, thread.value.projectId).settings
+ .continueThreadsAfterServerUpdate;
+ });
+
+ const runStopAll = Effect.fn("runStopAll")(function* () {
+ const stopSettings = yield* readStopSettings;
yield* flushAllTurnAnalytics;
const threadIds = yield* directory.listThreadIds();
const currentAdapters = yield* getAdapterEntries;
@@ -4215,15 +4251,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
),
).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions)));
yield* Effect.forEach(activeSessions, (session) =>
- Effect.flatMap(nowIso, (lastRuntimeEventAt) =>
- upsertSessionBinding(session, session.threadId, {
- ...(continueAfterRestart && session.status === "running" && session.activeTurnId
+ Effect.gen(function* () {
+ const continueAfterRestart =
+ session.status === "running" && session.activeTurnId
+ ? yield* continueAfterRestartFor(stopSettings, session.threadId)
+ : false;
+ const lastRuntimeEventAt = yield* nowIso;
+ yield* upsertSessionBinding(session, session.threadId, {
+ ...(continueAfterRestart && session.activeTurnId
? { continueAfterServerUpdate: session.activeTurnId }
: {}),
lastRuntimeEvent: "provider.stopAll",
lastRuntimeEventAt,
- }),
- ),
+ });
+ }),
).pipe(Effect.asVoid);
yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(
Effect.asVoid,
@@ -4262,10 +4303,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
if (currentAdapters.every(([, adapter]) => adapter.shutdown === undefined)) {
return yield* runStopAll();
}
- const continueAfterRestart = yield* serverSettings.getSettings.pipe(
- Effect.map((settings) => settings.continueThreadsAfterServerUpdate),
- Effect.orElseSucceed(() => false),
- );
+ const stopSettings = yield* readStopSettings;
const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => []));
yield* Effect.forEach(
currentAdapters,
@@ -4275,21 +4313,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
: Effect.gen(function* () {
const activeSessions = yield* adapter.listSessions();
yield* Effect.forEach(activeSessions, (session) =>
- Effect.flatMap(nowIso, (lastRuntimeEventAt) =>
- upsertSessionBinding(
+ Effect.gen(function* () {
+ const continueAfterRestart =
+ session.status === "running" && session.activeTurnId
+ ? yield* continueAfterRestartFor(stopSettings, session.threadId)
+ : false;
+ const lastRuntimeEventAt = yield* nowIso;
+ yield* upsertSessionBinding(
{ ...session, providerInstanceId: instanceId },
session.threadId,
{
- ...(continueAfterRestart &&
- session.status === "running" &&
- session.activeTurnId
+ ...(continueAfterRestart && session.activeTurnId
? { continueAfterServerUpdate: session.activeTurnId }
: {}),
lastRuntimeEvent: "provider.stopAll",
lastRuntimeEventAt,
},
- ),
- ),
+ );
+ }),
);
yield* adapter.stopAll().pipe(
Effect.ensuring(
diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json
index 562841004..4d6adb4ed 100644
--- a/apps/server/src/provider/model-manifest.json
+++ b/apps/server/src/provider/model-manifest.json
@@ -15,7 +15,7 @@
"providers": {
"claudeAgent": {
"defaults": {
- "chat": "claude-sonnet-5"
+ "chat": "claude-fable-5-1"
},
"profiles": {
"fable-5": {
@@ -32,12 +32,12 @@
},
{
"id": "medium",
- "label": "Medium"
+ "label": "Medium",
+ "isDefault": true
},
{
"id": "high",
- "label": "High",
- "isDefault": true
+ "label": "High"
},
{
"id": "xhigh",
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index e8ca6c477..48e48a0c8 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -73,6 +73,7 @@ import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletion
import * as RollbackSagaRunner from "./rollback/RollbackSagaRunner.ts";
import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
+import * as ProjectSettingsReactor from "./orchestration/ProjectSettingsReactor.ts";
import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";
import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
@@ -297,6 +298,7 @@ const ReactorLayerLive = Layer.empty.pipe(
Layer.provideMerge(ThreadSettlementReactor.layer),
Layer.provideMerge(PullRequestSyncReactor.layer),
Layer.provideMerge(ThreadPullRequestReactor.layer),
+ Layer.provideMerge(ProjectSettingsReactor.layer),
Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
Layer.provideMerge(RuntimeReceiptBusLive),
);
diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts
index 9e159d1a0..e31355333 100644
--- a/apps/server/src/serverRuntimeStartup.test.ts
+++ b/apps/server/src/serverRuntimeStartup.test.ts
@@ -1,5 +1,11 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
-import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
+import {
+ DEFAULT_MODEL,
+ DEFAULT_SERVER_SETTINGS,
+ ProjectId,
+ ProviderInstanceId,
+ ThreadId,
+} from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import * as Crypto from "effect/Crypto";
import * as Deferred from "effect/Deferred";
@@ -40,27 +46,43 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che
};
}),
} as unknown as GitVcsDriver.GitVcsDriver["Service"];
- const project = (workspaceRoot: string, autoPull = true) =>
- ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never;
-
- yield* ServerRuntimeStartup.autoPullProjects([
- project("/clean"),
- project("/current"),
- project("/dirty"),
- project("/ahead"),
- project("/feature"),
- project("/disabled", false),
- ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
+ const project = (workspaceRoot: string) =>
+ ({ id: ProjectId.make(workspaceRoot), workspaceRoot }) as never;
+ const overrides = (entries: Record) => ({
+ ...DEFAULT_SERVER_SETTINGS,
+ projectSettingsOverrides: Object.fromEntries(
+ Object.entries(entries).map(([root, defaultAutoPull]) => [
+ ProjectId.make(root),
+ { defaultAutoPull },
+ ]),
+ ),
+ });
+
+ yield* ServerRuntimeStartup.autoPullProjects(
+ [
+ project("/clean"),
+ project("/current"),
+ project("/dirty"),
+ project("/ahead"),
+ project("/feature"),
+ project("/disabled"),
+ ],
+ overrides({
+ "/clean": true,
+ "/current": true,
+ "/dirty": true,
+ "/ahead": true,
+ "/feature": true,
+ "/disabled": false,
+ }),
+ ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
assert.deepStrictEqual(pulled, ["/clean"]);
pulled.length = 0;
yield* ServerRuntimeStartup.autoPullProjects(
- [project("/inherited", false), project("/opted-out"), project("/dirty", false)],
- {
- defaultAutoPull: true,
- projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false },
- },
+ [project("/inherited"), project("/opted-out"), project("/dirty")],
+ { ...overrides({ "/opted-out": false }), defaultAutoPull: true },
).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
assert.deepStrictEqual(pulled, ["/inherited"]);
}),
@@ -204,12 +226,37 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
});
it.effect.each([
- { existing: false, machineModel: null, projectModel: null },
- { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null },
- { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null },
- { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" },
-])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) =>
+ {
+ existing: false,
+ machineModel: null,
+ projectModel: null,
+ machineMode: "full-access",
+ projectMode: null,
+ },
+ {
+ existing: false,
+ machineModel: "claude-sonnet-4-6",
+ projectModel: null,
+ machineMode: "approval-required",
+ projectMode: null,
+ },
+ {
+ existing: true,
+ machineModel: "claude-sonnet-4-6",
+ projectModel: null,
+ machineMode: "auto",
+ projectMode: null,
+ },
+ {
+ existing: true,
+ machineModel: "claude-sonnet-4-6",
+ projectModel: "gpt-5.4",
+ machineMode: "full-access",
+ projectMode: "auto-accept-edits",
+ },
+] as const)("auto-bootstrap model and permissions precedence: %j", (options) =>
Effect.gen(function* () {
+ const { existing, machineModel, projectModel, machineMode, projectMode } = options;
const machineSelection = machineModel
? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel }
: null;
@@ -221,10 +268,25 @@ it.effect.each([
readonly type: string;
readonly defaultModelSelection?: unknown;
readonly modelSelection?: unknown;
+ readonly runtimeMode?: unknown;
}>
>([]);
const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
- Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })),
+ Effect.provide(
+ ServerSettings.layerTest({
+ defaultModelSelection: machineSelection,
+ defaultRuntimeMode: machineMode,
+ projectSettingsOverrides:
+ existing && projectSelection
+ ? {
+ [ProjectId.make("existing-project")]: {
+ defaultModelSelection: projectSelection,
+ ...(projectMode ? { defaultRuntimeMode: projectMode } : {}),
+ },
+ }
+ : {},
+ }),
+ ),
Effect.provideService(ServerConfig.ServerConfig, {
cwd: "/tmp/startup-project",
autoBootstrapProjectFromCwd: true,
@@ -246,7 +308,7 @@ it.effect.each([
id: ProjectId.make("existing-project"),
title: "Startup Project",
workspaceRoot: "/tmp/startup-project",
- defaultModelSelection: projectSelection,
+ defaultModelSelection: null,
scripts: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -291,6 +353,7 @@ it.effect.each([
existing ? ["thread.create"] : ["project.create", "thread.create"],
);
if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false);
+ assert.equal(commands.at(-1)?.runtimeMode, projectMode ?? machineMode);
assert.deepStrictEqual(
commands.at(-1)?.modelSelection,
projectSelection ??
diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
index 3d8309120..87cd0b927 100644
--- a/apps/server/src/serverRuntimeStartup.ts
+++ b/apps/server/src/serverRuntimeStartup.ts
@@ -3,6 +3,7 @@ import {
DEFAULT_MODEL,
DEFAULT_PROVIDER_INTERACTION_MODE,
DEFAULT_SERVER_SETTINGS,
+ type ServerSettings as ServerSettingsValue,
type ModelSelection,
type OrchestrationProjectShell,
type OrchestrationSession,
@@ -11,7 +12,7 @@ import {
ThreadId,
TurnId,
} from "@t3tools/contracts";
-import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import * as Cause from "effect/Cause";
import * as Console from "effect/Console";
import * as Context from "effect/Context";
@@ -232,7 +233,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
nextProjectId = existingProject.value.id;
bootstrapProjectId = nextProjectId;
nextThreadModelSelection =
- existingProject.value.defaultModelSelection ?? defaultModelSelection;
+ resolveProjectSettings(settings, nextProjectId, existingProject.value).settings
+ .defaultModelSelection ?? defaultModelSelection;
}
yield* Effect.gen(function* () {
@@ -249,7 +251,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
title: "New thread",
modelSelection: nextThreadModelSelection,
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
- runtimeMode: "full-access",
+ runtimeMode: resolveProjectSettings(settings, nextProjectId).settings
+ .defaultRuntimeMode,
branch: null,
worktreePath: null,
createdAt,
@@ -485,14 +488,19 @@ export const reconcileProviderSessions = Effect.gen(function* () {
const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
const rollbackRepository = yield* Effect.serviceOption(RollbackSagaRepository);
const settings = yield* ServerSettings.ServerSettingsService;
- const continueAfterRestart = yield* settings.getSettings.pipe(
- Effect.map((value) => value.continueThreadsAfterServerUpdate),
+ const restartSettings = yield* settings.getSettings.pipe(
+ Effect.map(Option.some),
Effect.catch((cause) =>
Effect.logWarning("could not read restart continuation preference", { cause }).pipe(
- Effect.as(false),
+ Effect.as(Option.none()),
),
),
);
+ const continueAfterRestartFor = (projectId: ProjectId) =>
+ Option.isSome(restartSettings)
+ ? resolveProjectSettings(restartSettings.value, projectId).settings
+ .continueThreadsAfterServerUpdate
+ : false;
// Prime restart adoption installs rollback quarantine before exact-incarnation
// fencing releases any retained native frames.
@@ -610,7 +618,7 @@ export const reconcileProviderSessions = Effect.gen(function* () {
// Runtime events advance the projection's turn, but not the directory's
// last admitted turn. Use the projection to identify interrupted work.
const interruptedByRestart =
- continueAfterRestart &&
+ continueAfterRestartFor(thread.projectId) &&
session.status === "running" &&
session.activeTurnId !== null &&
Option.isSome(binding) &&
@@ -833,16 +841,13 @@ interface StartupOptions {
export const autoPullProjects = Effect.fn("autoPullProjects")(function* (
projects: ReadonlyArray,
- settings: Pick<
- typeof DEFAULT_SERVER_SETTINGS,
- "defaultAutoPull" | "projectAutoPullOverrides"
- > = DEFAULT_SERVER_SETTINGS,
+ settings: ServerSettingsValue = DEFAULT_SERVER_SETTINGS,
) {
const git = yield* GitVcsDriver.GitVcsDriver;
const workspaceRoots = [
...new Set(
projects
- .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull))
+ .filter((project) => resolveProjectSettings(settings, project.id).settings.defaultAutoPull)
.map((project) => project.workspaceRoot),
),
];
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index 6139988b7..aa7660d5f 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -1,6 +1,10 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import {
DEFAULT_SERVER_SETTINGS,
+ ModelSelection,
+ ProjectId,
+ ProjectMetaUpdatedPayload,
+ ProjectScript,
ProviderDriverKind,
ProviderInstanceId,
resolveProviderInstanceEnabled,
@@ -29,6 +33,39 @@ import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.t
const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);
const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
+const encodeLegacyProjectEditJson = Schema.encodeEffect(
+ Schema.fromJsonString(ProjectMetaUpdatedPayload),
+);
+const encodeModelSelectionJson = Schema.encodeEffect(Schema.fromJsonString(ModelSelection));
+const encodeProjectScriptsJson = Schema.encodeEffect(
+ Schema.fromJsonString(Schema.Array(ProjectScript)),
+);
+
+const appendLegacyProjectEdit = Effect.fn("appendLegacyProjectEdit")(function* (
+ version: number,
+ payload: typeof ProjectMetaUpdatedPayload.Type,
+) {
+ const sql = yield* SqlClient.SqlClient;
+ const encoded = yield* encodeLegacyProjectEditJson(payload);
+ yield* sql`
+ INSERT INTO orchestration_events (
+ event_id, aggregate_kind, stream_id, stream_version, event_type,
+ occurred_at, actor_kind, payload_json, metadata_json
+ ) VALUES (
+ ${`${payload.projectId}-${version}`}, ${"project"}, ${payload.projectId}, ${version},
+ ${"project.meta-updated"}, ${payload.updatedAt}, ${"client"}, ${encoded}, ${"{}"}
+ )
+ `;
+});
+
+const reloadSettings = Effect.gen(function* () {
+ const fresh = yield* ServerSettingsModule.ServerSettingsService;
+ return yield* fresh.getSettings;
+}).pipe(
+ Effect.provide(
+ Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)),
+ ),
+);
let providerMutationSequence = 0;
const updateSettingsWithProviderInstances = Effect.fn("updateSettingsWithProviderInstances")(
@@ -1535,4 +1572,218 @@ it.layer(NodeServices.layer)("server settings", (it) => {
assert.include(persisted, '"valueRedacted": true');
}).pipe(Effect.provide(makeServerSettingsLayer())),
);
+
+ it.effect("folds legacy project overrides into projectSettingsOverrides once", () =>
+ Effect.gen(function* () {
+ const serverConfig = yield* ServerConfig.ServerConfig;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const sql = yield* SqlClient.SqlClient;
+ const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
+ const legacyProject = ProjectId.make("project-legacy");
+ const scriptedProject = ProjectId.make("project-scripted");
+ const script: ProjectScript = {
+ id: "check",
+ name: "Check",
+ command: "npm test",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ };
+ const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5");
+ const modelJson = yield* encodeModelSelectionJson(model);
+ const scriptsJson = yield* encodeProjectScriptsJson([script]);
+ for (const [projectId, modelColumn, envMode, autoPull, scripts] of [
+ // The legacy project also carries aggregate scripts, but its stored
+ // null override reset them; the fold must not bring them back.
+ [legacyProject, modelJson, "worktree", 1, scriptsJson],
+ [scriptedProject, null, null, 0, scriptsJson],
+ ] as const) {
+ yield* sql`
+ INSERT INTO projection_projects (
+ project_id, title, workspace_root, default_model_selection_json,
+ default_thread_env_mode, auto_pull, scripts_json, created_at, updated_at
+ )
+ VALUES (
+ ${projectId}, ${"Project"}, ${`/tmp/${projectId}`}, ${modelColumn},
+ ${envMode}, ${autoPull}, ${scripts},
+ ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"}
+ )
+ `;
+ }
+ yield* fileSystem.writeFileString(
+ serverConfig.settingsPath,
+ `{"projectAgentBrowserAccessOverrides":{"${legacyProject}":false},"projectAutoPullOverrides":{"${scriptedProject}":true},"projectScriptOverrides":{"${legacyProject}":null}}`,
+ );
+
+ const settings = yield* serverSettings.getSettings;
+ assert.isTrue(settings.projectSettingsFolded);
+ assert.deepEqual(
+ settings.projectSettingsOverrides,
+ {
+ [legacyProject]: {
+ enableAgentBrowserAccess: false,
+ defaultModelSelection: model,
+ defaultThreadEnvMode: "worktree",
+ defaultAutoPull: true,
+ },
+ [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] },
+ },
+ );
+ // Derived legacy views keep older clients reading the same values.
+ assert.deepEqual(
+ settings.projectAutoPullOverrides,
+ {
+ [legacyProject]: true,
+ [scriptedProject]: true,
+ },
+ );
+ assert.deepEqual(settings.projectScriptOverrides, {
+ [scriptedProject]: [script],
+ });
+
+ // A reset survives the next load: the fold does not run again.
+ yield* serverSettings.updateSettings({
+ projectSettingsOverrides: { [legacyProject]: null },
+ });
+ const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
+ const persisted = yield* decodeServerSettings(
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ JSON.parse(raw),
+ );
+ assert.isTrue(persisted.projectSettingsFolded);
+ assert.isUndefined(persisted.projectSettingsOverrides[legacyProject]);
+ }).pipe(Effect.provide(makeServerSettingsLayer())),
+ );
+
+ it.effect("replays a committed legacy edit before the project projection catches up", () =>
+ Effect.gen(function* () {
+ const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
+ const projectId = ProjectId.make("unprojected-project");
+ const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5");
+ // No projection row exists yet: journal persistence already accepted the edit.
+ yield* appendLegacyProjectEdit(1, {
+ projectId,
+ defaultModelSelection: model,
+ defaultThreadEnvMode: "worktree",
+ updatedAt: "2026-09-12T00:00:00.000Z",
+ });
+ const settings = yield* serverSettings.getSettings;
+ assert.deepEqual(settings.projectSettingsOverrides[projectId], {
+ defaultModelSelection: model,
+ defaultThreadEnvMode: "worktree",
+ });
+ assert.equal(settings.projectSettingsLegacySequence, 1);
+ }).pipe(Effect.provide(makeServerSettingsLayer())),
+ );
+
+ it.effect(
+ "canonical reset consumes prior legacy events but a repeated later edit still applies",
+ () =>
+ Effect.gen(function* () {
+ const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
+ const projectId = ProjectId.make("reset-project");
+ const payload = {
+ projectId,
+ defaultThreadEnvMode: "worktree" as const,
+ updatedAt: "2026-09-12T00:00:00.000Z",
+ };
+ yield* serverSettings.getSettings;
+ yield* appendLegacyProjectEdit(1, payload);
+ // The reactor has not handled this event yet. The canonical write observes it.
+ const reset = yield* serverSettings.updateSettings({
+ projectSettingsOverrides: { [projectId]: null },
+ });
+ assert.isUndefined(reset.projectSettingsOverrides[projectId]);
+ assert.equal(reset.projectSettingsLegacySequence, 1);
+ const delayedReceipt = yield* serverSettings.updateSettings({});
+ assert.isUndefined(delayedReceipt.projectSettingsOverrides[projectId]);
+ assert.isUndefined((yield* reloadSettings).projectSettingsOverrides[projectId]);
+ // Same old aggregate value is a new user edit, not a stale projection value.
+ yield* appendLegacyProjectEdit(2, payload);
+ const edited = yield* serverSettings.updateSettings({});
+ assert.deepEqual(edited.projectSettingsOverrides[projectId], {
+ defaultThreadEnvMode: "worktree",
+ });
+ assert.equal(edited.projectSettingsLegacySequence, 2);
+ }).pipe(Effect.provide(makeServerSettingsLayer())),
+ );
+
+ it.effect("replays legacy edits after restart and clears only their legacy fields", () =>
+ Effect.gen(function* () {
+ const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
+ const projectId = ProjectId.make("restart-project");
+ const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5");
+ const script: ProjectScript = {
+ id: "check",
+ name: "Check",
+ command: "npm test",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ };
+ yield* serverSettings.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: {
+ enableAgentBrowserAccess: false,
+ },
+ },
+ });
+ yield* appendLegacyProjectEdit(1, {
+ projectId,
+ defaultModelSelection: model,
+ defaultThreadEnvMode: "worktree",
+ autoPull: true,
+ scripts: [script],
+ updatedAt: "2026-09-12T00:00:00.000Z",
+ });
+ // Simulate stopping after commit but before the live reactor handled it.
+ const recovered = yield* reloadSettings;
+ assert.deepEqual(recovered.projectSettingsOverrides[projectId], {
+ enableAgentBrowserAccess: false,
+ defaultModelSelection: model,
+ defaultThreadEnvMode: "worktree",
+ defaultAutoPull: true,
+ defaultProjectScripts: [script],
+ });
+ yield* appendLegacyProjectEdit(2, {
+ projectId,
+ defaultModelSelection: null,
+ defaultThreadEnvMode: null,
+ autoPull: false,
+ scripts: [],
+ updatedAt: "2026-09-12T00:00:01.000Z",
+ });
+ const cleared = yield* reloadSettings;
+ assert.deepEqual(cleared.projectSettingsOverrides[projectId], {
+ enableAgentBrowserAccess: false,
+ });
+ assert.isUndefined(cleared.projectScriptOverrides[projectId]);
+ assert.isUndefined(cleared.projectAutoPullOverrides[projectId]);
+ assert.equal(cleared.projectSettingsLegacySequence, 2);
+ }).pipe(Effect.provide(makeServerSettingsLayer())),
+ );
+
+ it.effect("leaves an unreadable settings.json untouched instead of folding over it", () =>
+ Effect.gen(function* () {
+ const serverConfig = yield* ServerConfig.ServerConfig;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const sql = yield* SqlClient.SqlClient;
+ const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
+ yield* sql`
+ INSERT INTO projection_projects (
+ project_id, title, workspace_root, auto_pull, scripts_json, created_at, updated_at
+ )
+ VALUES (
+ ${"project-broken"}, ${"Project"}, ${"/tmp/project-broken"}, ${1}, ${"[]"},
+ ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"}
+ )
+ `;
+ const broken = '{"defaultAutoPull": tru';
+ yield* fileSystem.writeFileString(serverConfig.settingsPath, broken);
+
+ const settings = yield* serverSettings.getSettings;
+ assert.isFalse(settings.projectSettingsFolded);
+ assert.deepEqual(settings.projectSettingsOverrides, {});
+ // The user's file is still there to repair; nothing was written over it.
+ assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken);
+ }).pipe(Effect.provide(makeServerSettingsLayer())),
+ );
});
diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts
index d32d6bbea..0f45df960 100644
--- a/apps/server/src/serverSettings.ts
+++ b/apps/server/src/serverSettings.ts
@@ -15,7 +15,10 @@ import {
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
DEFAULT_MODEL_BY_PROVIDER,
DEFAULT_SERVER_SETTINGS,
- type ModelSelection,
+ ModelSelection,
+ ProjectScript,
+ ProjectMetaUpdatedPayload,
+ type ProjectSettingsOverrides,
type ProviderInstanceConfig,
type ProviderInstanceEnvironmentVariable,
type UsageLimitSourceConfig,
@@ -63,6 +66,7 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
import {
applyServerSettingsPatch,
+ deriveLegacyProjectOverrides,
isModelSelectionProviderEnabled,
} from "@t3tools/shared/serverSettings";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
@@ -133,6 +137,7 @@ const normalizeServerSettings = (
encodeServerSettings(settings).pipe(
Effect.flatMap(decodeServerSettings),
Effect.map(foldProviderInstanceEnabledFlags),
+ Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })),
Effect.mapError(
(cause) =>
new ServerSettingsError({
@@ -622,6 +627,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([
"providerHealthRefreshInterval",
"sourceControlWriterModelSelection",
"textGenerationModelSelection",
+ "pullRequestMergeMethod",
]);
// Preserve both enabled states because provider history cannot recover a new opt-in.
@@ -669,6 +675,96 @@ function stripDefaultServerSettings(current: unknown, defaults: unknown): unknow
return Object.is(current, defaults) ? undefined : current;
}
+const decodeProjectScriptsJson = Schema.decodeUnknownOption(
+ Schema.fromJsonString(Schema.Array(ProjectScript)),
+);
+const decodeModelSelectionJson = Schema.decodeUnknownOption(
+ Schema.fromJsonString(Schema.NullOr(ModelSelection)),
+);
+const decodeLegacyProjectEditJson = Schema.decodeUnknownEffect(
+ Schema.fromJsonString(ProjectMetaUpdatedPayload),
+);
+
+interface LegacyProjectSettingsRow {
+ readonly projectId: string;
+ readonly defaultModelSelection: string | null;
+ readonly defaultThreadEnvMode: string | null;
+ readonly autoPull: number;
+ readonly scripts: string;
+}
+
+/**
+ * One-time fold of the legacy per-project fields into `projectSettingsOverrides`:
+ * the three `project*Overrides` maps and the settings columns on the project
+ * aggregate. Keys already present in the generic record win. Marked with
+ * `projectSettingsFolded` so a later reset in the UI survives restarts.
+ */
+function foldLegacyProjectSettings(
+ settings: ServerSettings,
+ rows: ReadonlyArray,
+): ServerSettings {
+ if (settings.projectSettingsFolded) return settings;
+ // Nothing to fold yet (fresh install): leave the marker off so the file
+ // stays sparse, and check again on the next load.
+ if (
+ rows.length === 0 &&
+ Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 &&
+ Object.keys(settings.projectAutoPullOverrides).length === 0 &&
+ Object.keys(settings.projectScriptOverrides).length === 0
+ ) {
+ return settings;
+ }
+ const entries: Record = {
+ ...settings.projectSettingsOverrides,
+ };
+ const set = (
+ projectId: string,
+ key: K,
+ value: ProjectSettingsOverrides[K] | undefined,
+ ) => {
+ if (value === undefined) return;
+ const entry = entries[projectId] ?? {};
+ if (Object.hasOwn(entry, key)) return;
+ entries[projectId] = { ...entry, [key]: value };
+ };
+ for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) {
+ set(projectId, "enableAgentBrowserAccess", value);
+ }
+ for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) {
+ set(projectId, "defaultAutoPull", value);
+ }
+ // A stored null meant "reset to machine defaults", which is now plain
+ // inheritance; the project's own aggregate scripts must not resurface.
+ const resetScripts = new Set();
+ for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) {
+ if (value === null) resetScripts.add(projectId);
+ else set(projectId, "defaultProjectScripts", value);
+ }
+ for (const row of rows) {
+ const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null");
+ if (Option.isSome(model) && model.value !== null) {
+ set(row.projectId, "defaultModelSelection", model.value);
+ }
+ if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") {
+ set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode);
+ }
+ if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true);
+ const scripts = decodeProjectScriptsJson(row.scripts);
+ if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) {
+ set(row.projectId, "defaultProjectScripts", scripts.value);
+ }
+ }
+ const projectSettingsOverrides = Object.fromEntries(
+ Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0),
+ );
+ return {
+ ...settings,
+ projectSettingsOverrides,
+ projectSettingsFolded: true,
+ ...deriveLegacyProjectOverrides({ projectSettingsOverrides }),
+ };
+}
+
const make = Effect.gen(function* () {
const { settingsPath } = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
@@ -715,9 +811,127 @@ const make = Effect.gen(function* () {
),
);
+ const writeSettingsAtomically = Effect.fnUntraced(
+ function* (settings: ServerSettings) {
+ const sparseSettingsJson = yield* encodeServerSettingsJson(
+ stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {},
+ );
+
+ return yield* writeFileStringAtomically({
+ filePath: settingsPath,
+ contents: `${sparseSettingsJson}\n`,
+ }).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, pathService),
+ );
+ },
+ Effect.mapError(
+ (cause) =>
+ new ServerSettingsError({
+ settingsPath,
+ operation: "write-file",
+ cause,
+ }),
+ ),
+ );
+
+ const readLegacyProjectRows = sql`
+ SELECT
+ project_id AS "projectId",
+ default_model_selection_json AS "defaultModelSelection",
+ default_thread_env_mode AS "defaultThreadEnvMode",
+ auto_pull AS "autoPull",
+ scripts_json AS "scripts"
+ FROM projection_projects
+ WHERE deleted_at IS NULL
+ `.pipe(
+ Effect.mapError(
+ (cause) =>
+ new ServerSettingsError({
+ settingsPath,
+ operation: "read-project-settings",
+ cause,
+ }),
+ ),
+ );
+
+ const reconcileLegacyProjectSettings = Effect.fn("ServerSettings.reconcileLegacyProjectSettings")(
+ function* (settings: ServerSettings) {
+ // The journal is durable even if the server stops before its live reactor
+ // observes the event. Capture rows and cursors in one SQL read transaction.
+ return yield* sql.withTransaction(
+ Effect.gen(function* () {
+ const [latest] = yield* sql<{
+ sequence: number;
+ }>`SELECT COALESCE(MAX(sequence), 0) AS sequence FROM orchestration_events`;
+ const head = latest?.sequence ?? 0;
+ const [projection] = yield* sql<{
+ sequence: number;
+ }>`SELECT last_applied_sequence AS sequence FROM projection_state WHERE projector = 'projection.projects'`;
+ const cursor = settings.projectSettingsLegacySequence ?? projection?.sequence ?? 0;
+ const rows = settings.projectSettingsFolded ? [] : yield* readLegacyProjectRows;
+ const folded = foldLegacyProjectSettings(settings, rows);
+ const events = yield* sql<{ payload: string }>`
+ SELECT payload_json AS payload FROM orchestration_events
+ WHERE sequence > ${cursor} AND sequence <= ${head}
+ AND event_type IN ('project.created', 'project.meta-updated')
+ ORDER BY sequence
+ `;
+ const entries: Record = {
+ ...folded.projectSettingsOverrides,
+ };
+ for (const event of events) {
+ const payload = yield* decodeLegacyProjectEditJson(event.payload);
+ const entry = { ...entries[payload.projectId] };
+ // In the old aggregate, null/empty/false mean inherit. Explicit false
+ // and empty-list overrides remain representable in the legacy maps.
+ if (payload.defaultModelSelection !== undefined) {
+ if (payload.defaultModelSelection === null) delete entry.defaultModelSelection;
+ else entry.defaultModelSelection = payload.defaultModelSelection;
+ }
+ if (payload.defaultThreadEnvMode !== undefined) {
+ if (payload.defaultThreadEnvMode === null) delete entry.defaultThreadEnvMode;
+ else entry.defaultThreadEnvMode = payload.defaultThreadEnvMode;
+ }
+ if (payload.autoPull !== undefined) {
+ if (payload.autoPull) entry.defaultAutoPull = true;
+ else delete entry.defaultAutoPull;
+ }
+ if (payload.scripts !== undefined) {
+ if (payload.scripts.length === 0) delete entry.defaultProjectScripts;
+ else entry.defaultProjectScripts = payload.scripts;
+ }
+ if (Object.keys(entry).length === 0) delete entries[payload.projectId];
+ else entries[payload.projectId] = entry;
+ }
+ if (
+ folded === settings &&
+ cursor === head &&
+ (settings.projectSettingsLegacySequence !== null || head === 0)
+ )
+ return settings;
+ return {
+ ...folded,
+ projectSettingsFolded: folded.projectSettingsFolded || head > 0,
+ projectSettingsLegacySequence: head,
+ projectSettingsOverrides: entries,
+ ...deriveLegacyProjectOverrides({ projectSettingsOverrides: entries }),
+ };
+ }),
+ );
+ },
+ Effect.mapError(
+ (cause) =>
+ new ServerSettingsError({ settingsPath, operation: "read-project-settings", cause }),
+ ),
+ );
+
const loadSettingsFromDisk = Effect.gen(function* () {
let settings = DEFAULT_SERVER_SETTINGS;
let persisted: typeof PersistedOptionalProviderSettings.Type = {};
+ // A file that failed to decode must stay on disk for the user to repair;
+ // the fold below only writes when it started from the file's real contents.
+ let settingsFileTrusted = true;
if (yield* readConfigExists) {
const raw = yield* readRawConfig;
@@ -728,6 +942,7 @@ const make = Effect.gen(function* () {
}
if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") {
const failure = decoded._tag === "Failure" ? decoded : persistedSettings;
+ settingsFileTrusted = false;
if (failure._tag === "Failure") {
yield* Effect.logWarning("failed to parse settings.json, using defaults", {
path: settingsPath,
@@ -766,9 +981,14 @@ const make = Effect.gen(function* () {
),
);
- return foldProviderInstanceEnabledFlags(
+ const loaded = foldProviderInstanceEnabledFlags(
restoreUsedProviders(settings, persisted, providerHistory),
);
+ const folded = settingsFileTrusted ? yield* reconcileLegacyProjectSettings(loaded) : loaded;
+ if (folded !== loaded) {
+ yield* writeSettingsAtomically(folded);
+ }
+ return folded;
});
const settingsCache = yield* Cache.make({
@@ -1021,30 +1241,6 @@ const make = Effect.gen(function* () {
};
});
- const writeSettingsAtomically = Effect.fnUntraced(
- function* (settings: ServerSettings) {
- const sparseSettingsJson = yield* encodeServerSettingsJson(
- stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {},
- );
-
- return yield* writeFileStringAtomically({
- filePath: settingsPath,
- contents: `${sparseSettingsJson}\n`,
- }).pipe(
- Effect.provideService(FileSystem.FileSystem, fs),
- Effect.provideService(Path.Path, pathService),
- );
- },
- Effect.mapError(
- (cause) =>
- new ServerSettingsError({
- settingsPath,
- operation: "write-file",
- cause,
- }),
- ),
- );
-
const revalidateAndEmit = writeSemaphore.withPermits(1)(
Effect.gen(function* () {
yield* Cache.invalidate(settingsCache, cacheKey);
@@ -1124,7 +1320,7 @@ const make = Effect.gen(function* () {
writeSemaphore.withPermits(1)(
Effect.gen(function* () {
yield* rejectLegacyProviderInstancesPatch(patch);
- const current = yield* getSettingsFromCache;
+ const current = yield* reconcileLegacyProjectSettings(yield* getSettingsFromCache);
const currentMaterialized = yield* materializeProviderEnvironmentSecrets(current);
const candidate = yield* normalizeServerSettings(
applyServerSettingsPatch(currentMaterialized, patch),
diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts
index ae5121324..c5751c6ef 100644
--- a/apps/server/src/vcs/VcsStatusBroadcaster.ts
+++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts
@@ -22,7 +22,7 @@ import type {
VcsStatusStreamEvent,
} from "@t3tools/contracts";
import { mergeGitStatusParts } from "@t3tools/shared/git";
-import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as GitWorkflowService from "../git/GitWorkflowService.ts";
@@ -160,7 +160,7 @@ export const autoPullPolicyLayer = Layer.effect(
const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd);
if (project._tag === "None") return false;
const settings = yield* serverSettings.getSettings;
- return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull);
+ return resolveProjectSettings(settings, project.value.id).settings.defaultAutoPull;
},
Effect.orElseSucceed(() => false),
),
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 11c840952..11f33a79f 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -84,6 +84,7 @@ import {
projectScriptRuntimeEnv,
resolveProjectScripts,
} from "@t3tools/shared/projectScripts";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { truncate } from "@t3tools/shared/String";
import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
@@ -173,7 +174,6 @@ import {
} from "../proposedPlan";
import {
DEFAULT_INTERACTION_MODE,
- DEFAULT_RUNTIME_MODE,
DEFAULT_THREAD_TERMINAL_ID,
MAX_TERMINALS_PER_GROUP,
type ChatMessage,
@@ -354,7 +354,6 @@ import {
environmentServerConfigsAtom,
primaryServerAvailableEditorsAtom,
primaryServerKeybindingsAtom,
- primaryServerSettingsAtom,
serverEnvironment,
} from "../state/server";
import { terminalEnvironment } from "../state/terminal";
@@ -1650,7 +1649,6 @@ export default function ChatView(props: ChatViewProps) {
}, [routeKind, routeThreadRef, routeThreadState]);
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const settings = useEnvironmentSettings(environmentId);
- const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
const setStickyComposerModelSelection = useComposerDraftStore(
(store) => store.setStickyModelSelection,
);
@@ -1941,17 +1939,14 @@ export default function ChatView(props: ChatViewProps) {
? buildLocalDraftThread(
threadId,
draftThread,
- fallbackDraftProject?.defaultModelSelection ??
- settings.defaultModelSelection ??
- NO_PROVIDER_MODEL_SELECTION,
+ resolveProjectSettings(
+ settings,
+ fallbackDraftProject?.id ?? null,
+ fallbackDraftProject ?? undefined,
+ ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION,
)
: undefined,
- [
- draftThread,
- fallbackDraftProject?.defaultModelSelection,
- settings.defaultModelSelection,
- threadId,
- ],
+ [draftThread, fallbackDraftProject, settings, threadId],
);
// Promotion is data-driven: the draft route keeps rendering while the
// server thread (same pre-allocated ref) starts, so live state must not
@@ -1979,7 +1974,11 @@ export default function ChatView(props: ChatViewProps) {
// session.lastError. Bump a tick so the banner hides immediately. Mirrors
// the branch mismatch banner.
const [, setThreadErrorBannerDismissTick] = useState(0);
- const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
+ const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null)
+ .settings.defaultRuntimeMode;
+ // Implicit drafts follow their current project/environment, including retargets.
+ // Explicit composer choices and existing server threads retain their permissions.
+ const runtimeMode = composerRuntimeMode ?? activeServerThread?.runtimeMode ?? defaultRuntimeMode;
const planModeEnabled = useClientSettings((clientSettings) => clientSettings.planModeEnabled);
// With legacy plan mode off, force the effective mode to "default" so a
// thread saved in plan mode is not stranded there with its toggle hidden.
@@ -2184,12 +2183,16 @@ export default function ChatView(props: ChatViewProps) {
[activeThread?.environmentId, activeThread?.projectId],
);
const activeProject = useProject(activeProjectRef);
+ // Environment settings with the active project's overrides applied.
+ const activeProjectSettings = useMemo(
+ () => resolveProjectSettings(settings, activeProject?.id ?? null, activeProject ?? undefined),
+ [activeProject, settings],
+ );
const activeProjectScripts = useMemo(
() => (activeProject ? resolveProjectScripts(settings, activeProject) : []),
[activeProject, settings],
);
- const activeProjectDefaultModelSelection =
- activeProject?.defaultModelSelection ?? settings.defaultModelSelection;
+ const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection;
const handleNewThreadInActiveProject = useCallback(() => {
startNewThreadForProject(activeProjectRef, handleNewThread);
}, [activeProjectRef, handleNewThread]);
@@ -2439,7 +2442,8 @@ export default function ChatView(props: ChatViewProps) {
setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
threadId: nextThreadId,
createdAt: new Date().toISOString(),
- runtimeMode: DEFAULT_RUNTIME_MODE,
+ runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings
+ .defaultRuntimeMode,
interactionMode: DEFAULT_INTERACTION_MODE,
...input,
});
@@ -2458,6 +2462,7 @@ export default function ChatView(props: ChatViewProps) {
navigate,
projectGroupingSettings,
routeKind,
+ settings,
setDraftThreadContext,
setLogicalProjectDraftThreadId,
],
@@ -4227,6 +4232,9 @@ export default function ChatView(props: ChatViewProps) {
],
);
+ const supportsProjectSettingsOverrides =
+ environmentById.get(environmentId)?.serverConfig?.environment.capabilities
+ .projectSettingsOverrides === true;
const persistProjectScripts = useCallback(
async (input: {
projectId: ProjectId;
@@ -4251,7 +4259,21 @@ export default function ChatView(props: ChatViewProps) {
const updateResult =
write.kind === "settings"
? mapAtomCommandResult(
- await updateProjectScriptSettings({ environmentId, input: { patch: write.patch } }),
+ await updateProjectScriptSettings({
+ environmentId,
+ input: {
+ patch: supportsProjectSettingsOverrides
+ ? {
+ projectSettingsOverrides: {
+ [input.projectId]: {
+ ...settings.projectSettingsOverrides[input.projectId],
+ defaultProjectScripts: input.nextScripts,
+ },
+ },
+ }
+ : write.patch,
+ },
+ }),
() => undefined,
)
: mapAtomCommandResult(
@@ -4281,7 +4303,15 @@ export default function ChatView(props: ChatViewProps) {
}
return updateResult;
},
- [environmentById, environmentId, updateProject, updateProjectScriptSettings, upsertKeybinding],
+ [
+ environmentById,
+ environmentId,
+ settings.projectSettingsOverrides,
+ supportsProjectSettingsOverrides,
+ updateProject,
+ updateProjectScriptSettings,
+ upsertKeybinding,
+ ],
);
const saveProjectScript = useCallback(
async (input: NewProjectScriptInput): Promise> => {
@@ -5700,7 +5730,7 @@ export default function ChatView(props: ChatViewProps) {
? (draftThread?.startFromOrigin ?? false)
: canOverrideServerThreadEnvMode
? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ??
- primaryServerSettings.newWorktreesStartFromOrigin)
+ activeProjectSettings.settings.newWorktreesStartFromOrigin)
: false;
const sendEnvMode = resolveSendEnvMode({
requestedEnvMode: envMode,
@@ -8713,7 +8743,7 @@ export default function ChatView(props: ChatViewProps) {
projectId: activeProject.id,
title: nextThreadTitle,
modelSelection: nextThreadModelSelection,
- runtimeMode,
+ runtimeMode: defaultRuntimeMode,
interactionMode: "default",
branch: activeThreadBranch,
worktreePath: activeThread.worktreePath,
@@ -8736,7 +8766,7 @@ export default function ChatView(props: ChatViewProps) {
},
modelSelection: ctxSelectedModelSelection,
titleSeed: nextThreadTitle,
- runtimeMode,
+ runtimeMode: defaultRuntimeMode,
interactionMode: "default",
sourceEpoch: 0,
sourceProposedPlan: {
@@ -8811,7 +8841,7 @@ export default function ChatView(props: ChatViewProps) {
isServerThread,
navigate,
resetLocalDispatch,
- runtimeMode,
+ defaultRuntimeMode,
startThreadTurn,
environmentId,
composerRef,
@@ -9216,7 +9246,7 @@ export default function ChatView(props: ChatViewProps) {
envMode: mode,
startFromOrigin: resolveNewDraftStartFromOrigin({
envMode: mode,
- newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
+ newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin,
}),
...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),
});
@@ -9228,7 +9258,7 @@ export default function ChatView(props: ChatViewProps) {
composerDraftTarget,
draftThread?.worktreePath,
isLocalDraftThread,
- primaryServerSettings.newWorktreesStartFromOrigin,
+ activeProjectSettings.settings.newWorktreesStartFromOrigin,
setPendingServerThreadEnvMode,
scheduleComposerFocus,
setDraftThreadContext,
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index 20a19edea..7afb0a9cb 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -1933,8 +1933,7 @@ function OpenCommandPaletteDialog(props: {
},
});
- // There is no projects listing page; the action targets the contextual
- // project (active thread/draft, falling back to the first sidebar group).
+ // Target the active thread or draft's project, falling back to the first sidebar group.
const contextualProjectGroup =
(contextualProjectRef
? projectGroupByTargetKey.get(
@@ -1986,8 +1985,6 @@ function OpenCommandPaletteDialog(props: {
run: async () => {
await navigate({
to: item.to,
- search: (previous) =>
- item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
hash: item.targetId ?? item.id,
replace: pathname === item.to,
hashScrollIntoView: false,
diff --git a/apps/web/src/components/ProjectEnvironmentBadge.tsx b/apps/web/src/components/ProjectEnvironmentBadge.tsx
new file mode 100644
index 000000000..22be8298a
--- /dev/null
+++ b/apps/web/src/components/ProjectEnvironmentBadge.tsx
@@ -0,0 +1,54 @@
+import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts";
+
+import type { SidebarProjectSnapshot } from "~/sidebarProjectGrouping";
+import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
+
+/**
+ * Machine icon for a project picker row whose group has a member on another
+ * environment, with the environment names in a tooltip. Projects that only
+ * live on this device render nothing, the rule thread rows use for their
+ * machine icon. Callers
+ * render it only while the catalog spans environments (see
+ * projectGroupsSpanEnvironments), so single-machine users see no change.
+ */
+export function ProjectEnvironmentBadge(props: {
+ readonly group: Pick;
+ readonly primaryEnvironmentId: EnvironmentId | null;
+ readonly machineByEnvironmentId: ReadonlyMap;
+}) {
+ // Member order follows registration order and can differ between sessions,
+ // so sort by label to keep the icon and tooltip stable.
+ const remoteMembers = props.group.memberProjects
+ .filter((member) => member.environmentId !== props.primaryEnvironmentId)
+ .map((member) => ({ ...member, environmentLabel: member.environmentLabel ?? "Remote" }))
+ .sort((a, b) => a.environmentLabel.localeCompare(b.environmentLabel));
+ const first = remoteMembers[0];
+ if (!first) return null;
+ const labels = remoteMembers
+ .map((member) => member.environmentLabel)
+ .filter((label, index, all) => all.indexOf(label) === index)
+ .join(", ");
+ const alsoHere = remoteMembers.length < props.group.memberProjects.length;
+ const description = `${alsoHere ? "Also on" : "On"} ${labels}`;
+ return (
+
+
+ }
+ >
+
+
+ {description}
+
+ );
+}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 67fe9aeec..97b250fd3 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -101,6 +101,7 @@ import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
import {
buildSidebarProjectSnapshots,
+ projectGroupsSpanEnvironments,
type SidebarProjectSnapshot,
} from "../sidebarProjectGrouping";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
@@ -138,6 +139,7 @@ import type { SidebarThreadSummary } from "../types";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { cn } from "~/lib/utils";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
+import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import {
animateSidebarLayoutChanges,
@@ -2420,6 +2422,13 @@ export default function Sidebar() {
],
[projectGroups],
);
+ // Same-named projects on two machines are only told apart by where they
+ // live, so rows on another machine carry its icon once the catalog spans
+ // more than one environment; a single-machine catalog stays as it was.
+ const showProjectEnvironments = useMemo(
+ () => projectGroupsSpanEnvironments(projectGroups),
+ [projectGroups],
+ );
const projectGroupByScopeKey = useMemo(
() => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
[projectGroups],
@@ -4527,6 +4536,13 @@ export default function Sidebar() {
{scopedProjectGroup?.displayName ?? "All projects"}
+ {scopedProjectGroup && showProjectEnvironments ? (
+
+ ) : null}
)}
{item.label}
+ {project && showProjectEnvironments ? (
+
+ ) : null}
{project ? (
= {
- "approval-required": {
- label: "Supervised",
- description: "Ask before commands and file changes.",
- icon: LockIcon,
- },
- "auto-accept-edits": {
- label: "Auto-accept edits",
- description: "Auto-approve edits, ask before other actions.",
- icon: PenLineIcon,
- },
- auto: {
- label: "Auto",
- description: "Supported providers approve routine actions; others still ask.",
- icon: SparklesIcon,
- },
- "full-access": {
- label: "Full access",
- description: "Allow commands and edits without prompts.",
- icon: LockOpenIcon,
- },
-};
-
-const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
const extendReplacementRangeForTrailingSpace = (
text: string,
rangeEnd: number,
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx
index 4a9421f20..07956c2b2 100644
--- a/apps/web/src/components/chat/DraftHeroHeadline.tsx
+++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx
@@ -1,6 +1,6 @@
import type { DraftId } from "~/composerDraftStore";
import { useComposerDraftStore } from "~/composerDraftStore";
-import type { ScopedProjectRef } from "@t3tools/contracts";
+import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
import { FolderPlusIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
@@ -12,9 +12,11 @@ import { selectProjectGroupingSettings } from "~/logicalProject";
import {
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
+ projectGroupsSpanEnvironments,
} from "~/sidebarProjectGrouping";
import { useProjects, useThreadShells } from "~/state/entities";
import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
+import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
import { ProjectFavicon } from "../ProjectFavicon";
import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
import {
@@ -27,6 +29,7 @@ import {
MenuTrigger,
} from "../ui/menu";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
interface DraftHeroHeadlineProps {
readonly draftId: DraftId | null;
@@ -82,6 +85,26 @@ export function DraftHeroHeadline({
threads,
],
);
+ // Same-named projects on two machines are only told apart by where they
+ // live, so rows on another machine carry its icon once the catalog spans
+ // more than one environment; a single-machine catalog stays as it was.
+ const showProjectEnvironments = useMemo(
+ () => projectGroupsSpanEnvironments(projectGroups),
+ [projectGroups],
+ );
+ const environmentMachineById = useMemo(
+ () =>
+ new Map(
+ environments.map(
+ (environment) =>
+ [
+ environment.environmentId,
+ resolveEnvironmentMachineKind(environment.serverConfig),
+ ] as const,
+ ),
+ ),
+ [environments],
+ );
const projectPickerEntries = useMemo(
() =>
buildSidebarProjectPickerEntries({
@@ -150,11 +173,13 @@ export function DraftHeroHeadline({
);
if (!hasExplicitComposerModelSelection(currentDraft)) {
applyStickyState(draftId);
- const defaultModelSelection =
- project.defaultModelSelection ??
- environments.find(
- (environment) => environment.environmentId === project.environmentId,
- )?.serverConfig?.settings.defaultModelSelection;
+ const environmentSettings = environments.find(
+ (environment) => environment.environmentId === project.environmentId,
+ )?.serverConfig?.settings;
+ const defaultModelSelection = environmentSettings
+ ? resolveProjectSettings(environmentSettings, project.id, project).settings
+ .defaultModelSelection
+ : project.defaultModelSelection;
if (defaultModelSelection) {
setModelSelection(draftId, defaultModelSelection, {
replaceOptions: true,
@@ -180,6 +205,13 @@ export function DraftHeroHeadline({
{group.displayName}
+ {showProjectEnvironments ? (
+
+ ) : null}
);
})}
diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx
index efe93cf82..ff7c1b534 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.test.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx
@@ -34,6 +34,7 @@ function renderPicker(input: {
model: string;
options: ReadonlyArray;
includeEntry?: boolean;
+ triggerLabel?: string;
}) {
const instanceId = ProviderInstanceId.make(input.instanceId);
const entry = providerEntry(input.instanceId, input.driver);
@@ -45,11 +46,25 @@ function renderPicker(input: {
instanceEntries={input.includeEntry === false ? [] : [entry]}
modelOptionsByInstance={new Map([[instanceId, input.options]])}
onInstanceModelChange={() => {}}
+ {...(input.triggerLabel ? { triggerLabel: input.triggerLabel } : {})}
/>,
);
}
describe("ProviderModelPicker", () => {
+ it("shows a neutral aggregate value without a representative model or availability badge", () => {
+ const markup = renderPicker({
+ instanceId: "codex_personal",
+ driver: "codex",
+ model: "gpt-5",
+ options: [{ slug: "gpt-5", name: "GPT 5", isUnavailable: true }],
+ triggerLabel: "Mixed values",
+ });
+ expect(markup).toContain("Mixed values");
+ expect(markup).not.toContain("GPT 5");
+ expect(markup).not.toContain("Unavailable");
+ });
+
it.each(["", ANTIGRAVITY_DEFAULT_MODEL])(
"shows a choice prompt before Antigravity has an account catalog for %s",
(model) => {
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index 68777c288..1fede296e 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -49,6 +49,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
open?: boolean;
triggerVariant?: VariantProps["variant"];
triggerClassName?: string;
+ /** Aggregate settings can show a neutral value without claiming one provider is selected. */
+ triggerLabel?: string;
triggerAriaLabel?: string;
onOpenChange?: (open: boolean) => void;
onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
@@ -183,7 +185,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
- {activeEntry ? (
+ {activeEntry && props.triggerLabel === undefined ? (
}
>
- {triggerTitle}
+ {props.triggerLabel ?? triggerTitle}
- {triggerLabel}
+ {props.triggerLabel ?? triggerLabel}
- {selectedModel?.isUnavailable ? (
+ {selectedModel?.isUnavailable && props.triggerLabel === undefined ? (
Unavailable
diff --git a/apps/web/src/components/chat/runtimeModeConfig.ts b/apps/web/src/components/chat/runtimeModeConfig.ts
new file mode 100644
index 000000000..4a4ce149b
--- /dev/null
+++ b/apps/web/src/components/chat/runtimeModeConfig.ts
@@ -0,0 +1,30 @@
+import type { RuntimeMode } from "@t3tools/contracts";
+import { type LucideIcon, LockIcon, LockOpenIcon, PenLineIcon, SparklesIcon } from "lucide-react";
+
+export const runtimeModeConfig: Record<
+ RuntimeMode,
+ { label: string; description: string; icon: LucideIcon }
+> = {
+ "approval-required": {
+ label: "Supervised",
+ description: "Ask before commands and file changes.",
+ icon: LockIcon,
+ },
+ "auto-accept-edits": {
+ label: "Auto-accept edits",
+ description: "Auto-approve edits, ask before other actions.",
+ icon: PenLineIcon,
+ },
+ auto: {
+ label: "Auto",
+ description: "Supported providers approve routine actions; others still ask.",
+ icon: SparklesIcon,
+ },
+ "full-access": {
+ label: "Full access",
+ description: "Allow commands and edits without prompts.",
+ icon: LockOpenIcon,
+ },
+};
+
+export const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
diff --git a/apps/web/src/components/projectScriptEditor.test.tsx b/apps/web/src/components/projectScriptEditor.test.tsx
new file mode 100644
index 000000000..becb3a369
--- /dev/null
+++ b/apps/web/src/components/projectScriptEditor.test.tsx
@@ -0,0 +1,245 @@
+import * as Cause from "effect/Cause";
+import { AsyncResult } from "effect/unstable/reactivity";
+import { act, StrictMode, type ReactNode } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+vi.mock("./ui/dialog", () => ({
+ Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null),
+ DialogDescription: "p",
+ DialogFooter: "footer",
+ DialogHeader: "header",
+ DialogPanel: "section",
+ DialogPopup: "section",
+ DialogTitle: "h2",
+}));
+vi.mock("./ui/alert-dialog", () => ({
+ AlertDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? children : null,
+ AlertDialogClose: "button",
+ AlertDialogDescription: "p",
+ AlertDialogFooter: "footer",
+ AlertDialogHeader: "header",
+ AlertDialogPopup: "section",
+ AlertDialogTitle: "h2",
+}));
+vi.mock("./ui/button", () => ({ Button: "button" }));
+vi.mock("./ui/input", () => ({ Input: "input" }));
+vi.mock("./ui/label", () => ({ Label: "label" }));
+vi.mock("./ui/popover", () => ({
+ Popover: ({ children }: { children: ReactNode }) => children,
+ PopoverPopup: () => null,
+ PopoverTrigger: "button",
+}));
+vi.mock("./ui/switch", () => ({ Switch: "input" }));
+vi.mock("./ui/textarea", () => ({ Textarea: "textarea" }));
+
+import {
+ EMPTY_PROJECT_SCRIPT_INPUT,
+ ProjectScriptEditorDialog,
+ type ProjectScriptActionResult,
+ type ProjectScriptEditorRequest,
+} from "./projectScriptEditor";
+
+const onSubmit = vi.fn[0]["onSubmit"]>();
+const onClose = vi.fn();
+const onDelete = vi.fn();
+let renderer: ReactTestRenderer | null;
+
+function request(name: string, error?: string): ProjectScriptEditorRequest {
+ return {
+ scriptId: name,
+ initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name, command: `run-${name}` },
+ ...(error === undefined ? {} : { error }),
+ };
+}
+
+function editor(nextRequest: ProjectScriptEditorRequest) {
+ return (
+
+
+
+ );
+}
+
+function open(nextRequest: ProjectScriptEditorRequest) {
+ act(() => {
+ if (renderer) renderer.update(editor(nextRequest));
+ else renderer = create(editor(nextRequest));
+ });
+}
+
+function submit(): Promise {
+ return renderer!.root.findByType("form").props.onSubmit({ preventDefault() {} });
+}
+
+function saveButton() {
+ return renderer!.root.findAllByType("button").find((button) => button.props.type === "submit")!;
+}
+
+function deferredSave() {
+ let resolve!: (result: ProjectScriptActionResult) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((resolveResult, rejectResult) => {
+ resolve = resolveResult;
+ reject = rejectResult;
+ });
+ return { promise, resolve, reject };
+}
+
+beforeEach(() => {
+ renderer = null;
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ onSubmit.mockReset();
+ onClose.mockReset();
+ onDelete.mockReset();
+});
+
+afterEach(async () => {
+ await act(async () => renderer?.unmount());
+ vi.unstubAllGlobals();
+});
+
+describe("project action editor save lifecycle", () => {
+ it("blocks repeated submits and edits until the current save completes", async () => {
+ const save = deferredSave();
+ onSubmit.mockReturnValue(save.promise);
+ open(request("build"));
+
+ let completion!: Promise;
+ act(() => {
+ completion = submit();
+ void submit();
+ });
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(saveButton().props.disabled).toBe(true);
+ expect(renderer!.root.findByType("fieldset").props.disabled).toBe(true);
+ const cancel = renderer!.root
+ .findAllByType("button")
+ .find((button) => button.children.includes("Cancel"))!;
+ expect(cancel.props.disabled).not.toBe(true);
+
+ await act(async () => {
+ save.resolve(AsyncResult.success(undefined));
+ await completion;
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not close a replacement request or release its in-flight save", async () => {
+ const first = deferredSave();
+ const second = deferredSave();
+ onSubmit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
+ open(request("build"));
+ let firstCompletion!: Promise;
+ act(() => {
+ firstCompletion = submit();
+ });
+
+ open(request("test"));
+ expect(saveButton().props.disabled).toBe(false);
+ expect(renderer!.root.findByProps({ id: "script-name" }).props.value).toBe("test");
+ let secondCompletion!: Promise;
+ act(() => {
+ secondCompletion = submit();
+ });
+
+ await act(async () => {
+ first.resolve(AsyncResult.success(undefined));
+ await firstCompletion;
+ });
+ expect(onClose).not.toHaveBeenCalled();
+ expect(saveButton().props.disabled).toBe(true);
+
+ await act(async () => {
+ second.resolve(AsyncResult.success(undefined));
+ await secondCompletion;
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]);
+ });
+
+ it.each(["failure", "rejection"] as const)(
+ "ignores a stale %s after the request changes",
+ async (outcome) => {
+ const save = deferredSave();
+ onSubmit.mockReturnValue(save.promise);
+ open(request("build"));
+ let completion!: Promise;
+ act(() => {
+ completion = submit();
+ });
+
+ open(request("test", "New request error"));
+ await act(async () => {
+ if (outcome === "failure")
+ save.resolve(AsyncResult.failure(Cause.fail(new Error("Old save error"))));
+ else save.reject(new Error("Old save error"));
+ await completion;
+ });
+
+ const messages = renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children);
+ expect(messages).toContain("New request error");
+ expect(messages).not.toContain("Old save error");
+ expect(saveButton().props.disabled).toBe(false);
+ expect(onClose).not.toHaveBeenCalled();
+ },
+ );
+
+ it("shows a current save error and allows retry", async () => {
+ onSubmit.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed"))));
+ onSubmit.mockResolvedValueOnce(AsyncResult.success(undefined));
+ open(request("build"));
+
+ await act(async () => {
+ await submit();
+ });
+ expect(renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children)).toContain(
+ "Save failed",
+ );
+ expect(saveButton().props.disabled).toBe(false);
+ expect(renderer!.root.findByType("fieldset").props.disabled).toBe(false);
+ expect(onClose).not.toHaveBeenCalled();
+
+ await act(async () => {
+ await submit();
+ });
+ expect(onSubmit).toHaveBeenCalledTimes(2);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(["cancel", "unmount"] as const)("ignores save completion after %s", async (exit) => {
+ const save = deferredSave();
+ onSubmit.mockReturnValue(save.promise);
+ open(request("build"));
+ let completion!: Promise;
+ act(() => {
+ completion = submit();
+ });
+
+ act(() => {
+ if (exit === "cancel") {
+ renderer!.root
+ .findAllByType("button")
+ .find((button) => button.children.includes("Cancel"))!
+ .props.onClick();
+ } else {
+ renderer!.unmount();
+ renderer = null;
+ }
+ });
+ onClose.mockClear();
+ await act(async () => {
+ save.resolve(AsyncResult.success(undefined));
+ await completion;
+ });
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx
index 4ffd45395..74b189a02 100644
--- a/apps/web/src/components/projectScriptEditor.tsx
+++ b/apps/web/src/components/projectScriptEditor.tsx
@@ -16,7 +16,14 @@ import {
PlayIcon,
WrenchIcon,
} from "lucide-react";
-import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react";
+import React, {
+ type FormEvent,
+ type KeyboardEvent,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
import {
keybindingValueForCommand,
@@ -156,9 +163,22 @@ export function ProjectScriptEditorDialog({
const [autoOpenPreview, setAutoOpenPreview] = useState(false);
const [validationError, setValidationError] = useState(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
+ const [savingRequest, setSavingRequest] = useState(null);
+ const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null);
const isOpen = request !== null;
const isEditing = request?.scriptId != null;
+ const isSaving = request !== null && savingRequest === request;
+
+ // A save completion must not affect a replacement request or an unmounted editor.
+ useLayoutEffect(
+ () => () => {
+ if (pendingSubmissionRef.current?.request === request) {
+ pendingSubmissionRef.current = null;
+ }
+ },
+ [request],
+ );
// Hydrate the form whenever a new request opens the dialog.
useEffect(() => {
@@ -172,8 +192,16 @@ export function ProjectScriptEditorDialog({
setPreviewUrl(request.initial.previewUrl ?? "");
setAutoOpenPreview(request.initial.autoOpenPreview);
setValidationError(request.error ?? null);
+ setSavingRequest(null);
}, [request]);
+ const close = () => {
+ pendingSubmissionRef.current = null;
+ setSavingRequest(null);
+ setIconPickerOpen(false);
+ onClose();
+ };
+
const captureKeybinding = (event: KeyboardEvent) => {
if (event.key === "Tab") return;
event.preventDefault();
@@ -188,7 +216,7 @@ export function ProjectScriptEditorDialog({
const submit = async (event: FormEvent) => {
event.preventDefault();
- if (!request) return;
+ if (!request || pendingSubmissionRef.current !== null) return;
const trimmedName = name.trim();
const trimmedCommand = command.trim();
if (trimmedName.length === 0) {
@@ -228,16 +256,31 @@ export function ProjectScriptEditorDialog({
return;
}
- const result = await onSubmit(request.scriptId, payload);
- if (result._tag === "Failure") {
- if (!isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
+ const submission = { request };
+ pendingSubmissionRef.current = submission;
+ setSavingRequest(request);
+ setIconPickerOpen(false);
+ try {
+ const result = await onSubmit(request.scriptId, payload);
+ if (pendingSubmissionRef.current === submission) {
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ setValidationError(error instanceof Error ? error.message : "Failed to save action.");
+ }
+ } else {
+ close();
+ }
+ }
+ } catch (error) {
+ if (pendingSubmissionRef.current === submission) {
setValidationError(error instanceof Error ? error.message : "Failed to save action.");
}
- return;
}
- setIconPickerOpen(false);
- onClose();
+ if (pendingSubmissionRef.current === submission) {
+ pendingSubmissionRef.current = null;
+ setSavingRequest(null);
+ }
};
return (
@@ -246,8 +289,7 @@ export function ProjectScriptEditorDialog({
open={isOpen}
onOpenChange={(open) => {
if (!open) {
- setIconPickerOpen(false);
- onClose();
+ close();
}
}}
>
@@ -259,112 +301,115 @@ export function ProjectScriptEditorDialog({
-
- }
- />
-
-
-
-
-
- Remove project
-
- }
- />
-
-
+ />
+ ) : null}
+ >
+ )}
+
);
}
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx
index 2b3f0544b..13e1670bc 100644
--- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx
@@ -1,4 +1,3 @@
-import { useAtomValue } from "@effect/atom-react";
import {
isAtomCommandInterrupted,
mapAtomCommandResult,
@@ -8,103 +7,28 @@ import {
} from "@t3tools/client-runtime/state/runtime";
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { AsyncResult } from "effect/unstable/reactivity";
-import {
- deriveProjectGroupingOverrideKey,
- selectProjectGroupingSettings,
-} from "../../logicalProject";
-import {
- type EnvironmentId,
- type ModelSelection,
- type ProjectIconOverride,
- type ProjectId,
- type ProjectScript,
- type ResolvedKeybindingsConfig,
- type ServerSettings,
- type ProviderDriverKind,
- type PullRequestMergeMethod,
- type SidebarProjectGroupingMode,
- type T3ProjectFileScript,
- type ThreadEnvMode,
-} from "@t3tools/contracts";
-import { resolveEnvModeLabel } from "../BranchToolbar.logic";
-import { createModelSelection } from "@t3tools/shared/model";
-import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
-import {
- projectScriptsInheritDefaults,
- resolveProjectScripts,
-} from "@t3tools/shared/projectScripts";
-import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
-import { useNavigate } from "@tanstack/react-router";
-import * as Equal from "effect/Equal";
+import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts";
+import { useLocation, useNavigate } from "@tanstack/react-router";
import * as Cause from "effect/Cause";
-import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react";
+import { Trash2Icon } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useComposerDraftStore } from "../../composerDraftStore";
-import {
- useClientSettings,
- useEnvironmentSettings,
- useUpdateClientSettings,
-} from "../../hooks/useSettings";
-import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
-import { ProjectActionsList } from "./ProjectActionsList";
-import { isElectron } from "../../env";
-import {
- decodeProjectScriptKeybindingRule,
- keybindingValueForCommand,
-} from "../../lib/projectScriptKeybindings";
-import {
- buildProjectScript,
- commandForProjectScript,
- nextProjectScriptId,
-} from "../../projectScripts";
import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
import { readLocalApi } from "../../localApi";
import {
- applyProviderInstanceSettings,
- deriveProviderInstanceEntries,
- resolveDefaultProviderModelSelection,
- sortProviderInstanceEntries,
-} from "../../providerInstances";
-import { getCustomModelOptionsByInstance } from "../../modelSelection";
-import {
- buildSidebarProjectSnapshots,
type SidebarProjectGroupMember,
type SidebarProjectSnapshot,
} from "../../sidebarProjectGrouping";
import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
-import { useProjects, useThreadShells } from "../../state/entities";
+import { useThreadShells } from "../../state/entities";
import { projectEnvironment } from "../../state/projects";
-import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
-import { ProviderModelPicker } from "../chat/ProviderModelPicker";
-import { TraitsPicker } from "../chat/TraitsPicker";
import { ProjectFavicon } from "../ProjectFavicon";
-import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
-import {
- EMPTY_PROJECT_SCRIPT_INPUT,
- editorRequestForScript,
- ProjectScriptEditorDialog,
- ScriptIcon,
- type NewProjectScriptInput,
- type ProjectScriptEditorRequest,
-} from "../projectScriptEditor";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
-import {
- Menu,
- MenuGroup,
- MenuGroupLabel,
- MenuItem,
- MenuPopup,
- MenuSeparator,
- MenuTrigger,
-} from "../ui/menu";
-import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
-import { Switch } from "../ui/switch";
import { stackedThreadToast, toastManager } from "../ui/toast";
import {
- SETTINGS_PICKER_TRIGGER_CLASSNAME,
SettingResetButton,
SettingsPageContainer,
SettingsRow,
@@ -114,15 +38,9 @@ import {
canPickExternalProjectFavicon,
ProjectFaviconPickerDialog,
} from "./ProjectFaviconPickerDialog";
-import {
- planProjectOverrideWrites,
- projectGroupTitleNeedsUpdate,
- resolveProjectScriptsWrite,
- supportsProjectDefaults,
-} from "./ProjectSettingsPanel.logic";
-
-const PROJECT_BROWSER_ACCESS_UPDATE_HINT =
- "Update every environment in this project group to override agent browser access.";
+import { ProjectActionsSettings } from "./ProjectActionsSettings";
+import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
+import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
const ProjectIconPickerDialog = lazy(() =>
import("./ProjectIconPickerDialog").then((module) => ({
@@ -130,58 +48,34 @@ const ProjectIconPickerDialog = lazy(() =>
})),
);
-export const PROJECT_GROUPING_MODE_LABELS: Record = {
- repository: "Group by repository",
- repository_path: "Group by repository path",
- separate: "Keep separate",
-};
-
-/** Logical project groups for the settings page, sorted by display name. */
-export function useSettingsProjectGroups(): SidebarProjectSnapshot[] {
- const projects = useProjects();
- const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
- const primaryEnvironmentId = usePrimaryEnvironmentId();
- const { environments } = useEnvironments();
- const environmentLabelById = useMemo(
- () =>
- new Map(
- environments.map((environment) => [environment.environmentId, environment.label] as const),
- ),
- [environments],
- );
- return useMemo(
- () =>
- buildSidebarProjectSnapshots({
- projects,
- settings: projectGroupingSettings,
- primaryEnvironmentId,
- resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
- }).sort((a, b) => a.displayName.localeCompare(b.displayName)),
- [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects],
- );
-}
-
function memberKey(member: { environmentId: string; id: string }): string {
return `${member.environmentId}:${member.id}`;
}
+export type ProjectSettingsCategory = "general" | "integrations" | "source-control";
+
export function ProjectSettingsPanel({
projectKey,
environmentId = null,
+ checkoutKey = null,
}: {
projectKey: string;
environmentId?: EnvironmentId | null;
+ checkoutKey?: string | null;
}) {
const groups = useSettingsProjectGroups();
- const navigate = useNavigate();
+ const navigate = useNavigate({ from: "/settings" });
+ const pathname = useLocation({ select: (location) => location.pathname });
const selected = groups.find((group) => group.projectKey === projectKey) ?? null;
const members = useMemo(
() =>
selected?.memberProjects.filter(
- (member) => environmentId === null || member.environmentId === environmentId,
+ (member) =>
+ (environmentId === null || member.environmentId === environmentId) &&
+ (checkoutKey === null || member.physicalProjectKey === checkoutKey),
) ?? [],
- [selected, environmentId],
+ [selected, environmentId, checkoutKey],
);
// Remember the members of the last rendered group so a grouping-rule change
@@ -189,6 +83,7 @@ export function ProjectSettingsPanel({
const lastSelectionRef = useRef<{
key: string;
environmentId: EnvironmentId | null;
+ checkoutKey: string | null;
memberKeys: string[];
} | null>(null);
useEffect(() => {
@@ -196,28 +91,38 @@ export function ProjectSettingsPanel({
lastSelectionRef.current = {
key: selected.projectKey,
environmentId,
+ checkoutKey,
memberKeys: members.map((member) => member.physicalProjectKey),
};
- }, [selected, members, environmentId]);
+ }, [selected, members, environmentId, checkoutKey]);
// A grouping-rule change replaces the group key mid-visit; follow the
// project to its new key instead of parking on the not-found state.
useEffect(() => {
if (members.length > 0) return;
const last = lastSelectionRef.current;
- if (last?.key !== projectKey || last.environmentId !== environmentId) return;
+ if (
+ last?.key !== projectKey ||
+ last.environmentId !== environmentId ||
+ last.checkoutKey !== checkoutKey
+ )
+ return;
const successor = groups.find((group) =>
group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),
);
if (successor) {
void navigate({
- to: "/settings/projects",
- search: { project: successor.projectKey, machine: environmentId ?? undefined },
+ to: pathname,
+ search: () => ({
+ project: successor.projectKey,
+ machine: environmentId ?? undefined,
+ checkout: checkoutKey ?? undefined,
+ }),
replace: true,
hashScrollIntoView: false,
});
}
- }, [groups, navigate, projectKey, members.length, environmentId]);
+ }, [groups, navigate, pathname, projectKey, members.length, environmentId, checkoutKey]);
if (!selected) {
return (
@@ -231,7 +136,7 @@ export function ProjectSettingsPanel({
if (members.length === 0)
return (
- This project has no checkout on this machine.
+ This checkout is no longer available in the selected project and environment.
);
const scopedGroup = {
@@ -242,165 +147,13 @@ export function ProjectSettingsPanel({
};
return (
);
}
-function reportScriptFailure(result: AtomCommandResult) {
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add({
- type: "error",
- title: "Failed to save project actions",
- description: error instanceof Error ? error.message : "An error occurred.",
- });
- }
- return mapAtomCommandResult(result, () => undefined);
-}
-
-export function useProjectScriptSettings(
- targets: readonly {
- environmentId: EnvironmentId;
- settings: ServerSettings;
- keybindings: ResolvedKeybindingsConfig;
- supportsProjectDefaults: boolean;
- project?: { id: ProjectId; scripts: readonly ProjectScript[] };
- }[],
-) {
- const projects = useProjects();
- const [saving, setSaving] = useState(false);
- const savingRef = useRef(false);
- const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update");
- const updateProject = useAtomCommand(projectEnvironment.update, "project actions update");
- const upsertKeybinding = useAtomCommand(
- serverEnvironment.upsertKeybinding,
- "action shortcut update",
- );
- const removeKeybinding = useAtomCommand(
- serverEnvironment.removeKeybinding,
- "action shortcut removal",
- );
-
- async function persist(
- transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null,
- scriptId?: string,
- keybinding?: string | null,
- ): Promise> {
- if (savingRef.current || targets.length === 0) {
- const message = "No available machine, or another action change is saving.";
- toastManager.add({ type: "error", title: "Actions not saved", description: message });
- return AsyncResult.failure(Cause.fail(new Error(message)));
- }
- savingRef.current = true;
- setSaving(true);
- try {
- for (const target of targets) {
- const { environmentId, settings, keybindings, project } = target;
- const current = project
- ? resolveProjectScripts(settings, project)
- : settings.defaultProjectScripts;
- const nextScripts = transform(current);
- const effectiveScripts = nextScripts ?? settings.defaultProjectScripts;
- const write = resolveProjectScriptsWrite({
- supportsProjectDefaults: target.supportsProjectDefaults,
- projectId: project?.id ?? null,
- nextScripts,
- });
- if (write.kind === "unsupported") {
- const message = "Update this machine to save default actions.";
- toastManager.add({ type: "error", title: "Actions not saved", description: message });
- return AsyncResult.failure(Cause.fail(new Error(message)));
- }
- const result =
- write.kind === "settings"
- ? await updateSettings({ environmentId, input: { patch: write.patch } })
- : await updateProject({
- environmentId,
- input: { projectId: write.projectId, scripts: write.scripts },
- });
- if (result._tag === "Failure") return reportScriptFailure(result);
- if (!isElectron) continue;
- const changedIds = scriptId
- ? [scriptId]
- : current
- .filter((script) => !effectiveScripts.some((next) => next.id === script.id))
- .map((script) => script.id);
- for (const id of changedIds) {
- const command = commandForProjectScript(id);
- const previousValue = keybindingValueForCommand(keybindings, command);
- const previous = previousValue
- ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command })
- : null;
- const next = decodeProjectScriptKeybindingRule({ keybinding, command });
- const retainedElsewhere =
- !nextScripts?.some((script) => script.id === id) &&
- ((project && settings.defaultProjectScripts.some((script) => script.id === id)) ||
- Object.entries(settings.projectScriptOverrides).some(
- ([projectId, scripts]) =>
- projectId !== project?.id && scripts?.some((script) => script.id === id),
- ) ||
- projects.some(
- (other) =>
- other.environmentId === environmentId &&
- other.id !== project?.id &&
- (project ? resolveProjectScripts(settings, other) : other.scripts).some(
- (script) => script.id === id,
- ),
- ));
- const bindingResult = next
- ? await upsertKeybinding({
- environmentId,
- input:
- previous && previous.key !== next.key ? { ...next, replace: previous } : next,
- })
- : previous && !retainedElsewhere
- ? await removeKeybinding({ environmentId, input: previous })
- : null;
- if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult);
- }
- }
- return AsyncResult.success(undefined);
- } finally {
- savingRef.current = false;
- setSaving(false);
- }
- }
-
- function submit(scriptId: string | null, input: NewProjectScriptInput) {
- const existingIds = [
- ...projects.flatMap((project) => project.scripts.map((script) => script.id)),
- ...targets.flatMap(({ settings, project }) =>
- [
- ...settings.defaultProjectScripts,
- ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []),
- ...(project?.scripts ?? []),
- ].map((script) => script.id),
- ),
- ];
- const id = scriptId ?? nextProjectScriptId(input.name, existingIds);
- const next = buildProjectScript(id, input);
- return persist(
- (current) => {
- const updated = current.map((script) =>
- script.id === id
- ? next
- : input.runOnWorktreeCreate
- ? { ...script, runOnWorktreeCreate: false }
- : script,
- );
- return scriptId === null ? [...updated, next] : updated;
- },
- id,
- input.keybinding,
- );
- }
-
- return { saving, persist, submit };
-}
-
function ProjectDetail({
group,
hasOtherMembers,
@@ -408,7 +161,7 @@ function ProjectDetail({
group: SidebarProjectSnapshot;
hasOtherMembers: boolean;
}) {
- const navigate = useNavigate();
+ const navigate = useNavigate({ from: "/settings" });
const primaryEnvironmentId = usePrimaryEnvironmentId();
const { environments } = useEnvironments();
const environmentById = useMemo(
@@ -419,121 +172,10 @@ function ProjectDetail({
group.memberProjects.find(
(member) => environmentById.get(member.environmentId)?.serverConfig != null,
) ?? group.memberProjects[0]!;
- // Provider instances and model options belong to the environment that runs
- // the project's threads. The hosted app has no primary environment, so
- // reading them from there would show "No providers available" everywhere.
- const projectSettings = useEnvironmentSettings(representative.environmentId);
- const serverProviders =
- useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ??
- EMPTY_SERVER_PROVIDERS;
- const updateClientSettings = useUpdateClientSettings();
- const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const threads = useThreadShells();
const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
- const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting");
- const [savingBrowserAccess, setSavingBrowserAccess] = useState(false);
- const savingBrowserAccessRef = useRef(false);
- const browserOverrides = group.memberProjects.map(
- (member) =>
- environmentById.get(member.environmentId)?.serverConfig?.settings
- .projectAgentBrowserAccessOverrides[member.id],
- );
- const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id];
- const browserMixed = group.memberProjects.some((member, index) => {
- const settings = environmentById.get(member.environmentId)?.serverConfig?.settings;
- if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false;
- return (
- browserOverrides[index] !== browserOverride ||
- (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !==
- (browserOverride ?? projectSettings.enableAgentBrowserAccess)
- );
- });
- // An offline checkout does not block the row; saving asks for it to connect.
- const browserOverridesSupported = group.memberProjects.every((member) => {
- const config = environmentById.get(member.environmentId)?.serverConfig;
- return config == null || supportsProjectDefaults(config);
- });
- const setBooleanOverride = async (
- key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides",
- enabled: boolean | undefined,
- ) => {
- if (savingBrowserAccessRef.current) return;
- savingBrowserAccessRef.current = true;
- setSavingBrowserAccess(true);
- try {
- const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId));
- for (const environmentId of environmentIds) {
- const environment = environmentById.get(environmentId);
- if (!environment?.serverConfig || environment.connection.phase !== "connected") {
- toastManager.add({
- type: "warning",
- title: "Setting not saved",
- description: `Connect ${environment?.label ?? "this machine"} and try again.`,
- });
- return;
- }
- }
- const writes = planProjectOverrideWrites({
- key,
- enabled,
- members: group.memberProjects,
- supportsProjectDefaults: (environmentId) =>
- supportsProjectDefaults(environmentById.get(environmentId)?.serverConfig),
- });
- if (writes === null) {
- toastManager.add({
- type: "warning",
- title: "Setting not saved",
- description: PROJECT_BROWSER_ACCESS_UPDATE_HINT,
- });
- return;
- }
- for (const write of writes) {
- const environmentId =
- write.kind === "settings" ? write.environmentId : write.member.environmentId;
- const result =
- write.kind === "settings"
- ? mapAtomCommandResult(
- await updateServerSettings({ environmentId, input: { patch: write.patch } }),
- () => undefined,
- )
- : mapAtomCommandResult(
- await updateProject({
- environmentId,
- input: { projectId: write.member.id, autoPull: write.autoPull },
- }),
- () => undefined,
- );
- if (result._tag === "Failure") {
- reportFailure(
- `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`,
- result,
- );
- return;
- }
- }
- } finally {
- savingBrowserAccessRef.current = false;
- setSavingBrowserAccess(false);
- }
- };
- const setBrowserAccess = (enabled: boolean | undefined) =>
- setBooleanOverride("projectAgentBrowserAccessOverrides", enabled);
const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
const projectNameEditedRef = useRef(false);
- const mergeMethodOverrides = useClientSettings(
- (settings) => settings.pullRequestMergeMethodOverrides,
- );
- const projectMergeMethod = mergeMethodOverrides[group.projectKey];
- const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => {
- const nextOverrides = { ...mergeMethodOverrides };
- if (method === null) {
- delete nextOverrides[group.projectKey];
- } else {
- nextOverrides[group.projectKey] = method;
- }
- updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides });
- };
const faviconPath = representative.faviconPath ?? null;
const projectIcon = representative.projectIcon ?? null;
@@ -569,14 +211,23 @@ function ProjectDetail({
async (
input: Partial<{
title: string;
- defaultModelSelection: ModelSelection | null;
- defaultThreadEnvMode: ThreadEnvMode | null;
- autoPull: boolean;
faviconPath: string | null;
projectIcon: ProjectIconOverride | null;
}>,
failureTitle: string,
): Promise> => {
+ const unavailable = group.memberProjects.find((member) => {
+ const environment = environmentById.get(member.environmentId);
+ return environment?.connection.phase !== "connected" || !environment.serverConfig;
+ });
+ if (unavailable) {
+ const error = new Error(
+ `Connect ${unavailable.environmentLabel ?? "the selected environment"} and try again.`,
+ );
+ const result: AtomCommandResult = AsyncResult.failure(Cause.fail(error));
+ reportFailure(failureTitle, result);
+ return result;
+ }
for (const member of group.memberProjects) {
const result = mapAtomCommandResult(
await updateProject({
@@ -599,7 +250,7 @@ function ProjectDetail({
}
return AsyncResult.success(undefined);
},
- [group.memberProjects, reportFailure, updateProject],
+ [environmentById, group.memberProjects, reportFailure, updateProject],
);
const renameGroup = useCallback(
@@ -623,117 +274,6 @@ function ProjectDetail({
[group.memberProjects, updateAllMembers],
);
- // ----- default model -----
- const storedSelection = representative.defaultModelSelection;
- const resolvedSelection = resolveDefaultProviderModelSelection(
- serverProviders,
- storedSelection ?? projectSettings.defaultModelSelection,
- );
- const mixedModel = group.memberProjects.some((member) => {
- const config = environmentById.get(member.environmentId)?.serverConfig;
- return (
- !Equal.equals(member.defaultModelSelection, storedSelection) ||
- (config !== null &&
- config !== undefined &&
- environmentById.get(representative.environmentId)?.serverConfig != null &&
- JSON.stringify(
- resolveDefaultProviderModelSelection(
- config.providers,
- member.defaultModelSelection ?? config.settings.defaultModelSelection,
- ),
- ) !== JSON.stringify(resolvedSelection))
- );
- });
- const resolvedInstanceId = resolvedSelection?.instanceId ?? null;
- const resolvedModel = resolvedSelection?.model ?? null;
- const instanceEntries = useMemo(
- () =>
- sortProviderInstanceEntries(
- applyProviderInstanceSettings(
- deriveProviderInstanceEntries(serverProviders),
- projectSettings,
- ),
- ),
- [serverProviders, projectSettings],
- );
- const modelOptionsByInstance = useMemo(
- () =>
- getCustomModelOptionsByInstance(
- projectSettings,
- serverProviders,
- resolvedInstanceId,
- resolvedModel,
- ),
- [resolvedInstanceId, resolvedModel, serverProviders, projectSettings],
- );
- const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId);
- const setDefaultModel = (selection: ModelSelection | null) => {
- if (selection !== null) {
- for (const member of group.memberProjects) {
- const environment = environmentById.get(member.environmentId);
- const config = environment?.serverConfig;
- const entry = config
- ? applyProviderInstanceSettings(
- deriveProviderInstanceEntries(config.providers),
- config.settings,
- ).find((candidate) => candidate.instanceId === selection.instanceId)
- : undefined;
- const options = config
- ? getCustomModelOptionsByInstance(
- { ...projectSettings, ...config.settings },
- config.providers,
- ).get(selection.instanceId)
- : undefined;
- if (
- !entry?.enabled ||
- !entry.isAvailable ||
- !options?.some((model) => model.slug === selection.model && !model.isUnavailable)
- ) {
- toastManager.add({
- type: "warning",
- title: "Project model not saved",
- description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`,
- });
- return;
- }
- }
- }
- void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model");
- };
-
- // ----- new-thread workspace mode -----
- const storedEnvMode = representative.defaultThreadEnvMode ?? null;
- const mixedWorkspace = group.memberProjects.some(
- (member) => member.defaultThreadEnvMode !== storedEnvMode,
- );
- const setDefaultThreadEnvMode = useCallback(
- (mode: ThreadEnvMode | null) =>
- void updateAllMembers(
- { defaultThreadEnvMode: mode },
- "Failed to update new-thread workspace",
- ),
- [updateAllMembers],
- );
-
- const autoPull = resolveProjectAutoPull(
- projectSettings,
- representative.id,
- representative.autoPull,
- );
- const autoPullOverridden = group.memberProjects.some(
- (member) =>
- member.autoPull ||
- environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[
- member.id
- ] !== undefined,
- );
- const mixedAutoPull = group.memberProjects.some((member) => {
- const settings = environmentById.get(member.environmentId)?.serverConfig?.settings;
- return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull;
- });
- const setAutoPull = (enabled: boolean | undefined) =>
- setBooleanOverride("projectAutoPullOverrides", enabled);
-
// ----- project icon -----
const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
const [iconPickerOpen, setIconPickerOpen] = useState(false);
@@ -761,107 +301,7 @@ function ProjectDetail({
[supportsProjectIcons, updateAllMembers],
);
- // ----- checkout selection and scripts -----
const hasMultipleCheckouts = group.memberProjects.length > 1;
- const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null);
- const selectedCheckoutMatch = group.memberProjects.find(
- (member) => member.physicalProjectKey === selectedCheckoutKey,
- );
- const selectedCheckout = selectedCheckoutMatch ?? representative;
- const selectedServerConfig = useAtomValue(
- serverEnvironment.configValueAtom(selectedCheckout.environmentId),
- );
- const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
- const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId);
- const scripts = resolveProjectScripts(scriptSettings, selectedCheckout);
- const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout);
- // Older servers keep actions on the checkout itself, with no machine defaults to inherit.
- const scriptDefaultsSupported = supportsProjectDefaults(selectedServerConfig);
- const [editorRequest, setEditorRequest] = useState(null);
- const {
- saving: isSavingScripts,
- persist: persistScripts,
- submit: submitScript,
- } = useProjectScriptSettings(
- // Until the checkout's server reports its capabilities there is no telling where actions are kept.
- selectedServerConfig
- ? [
- {
- environmentId: selectedCheckout.environmentId,
- settings: scriptSettings,
- keybindings,
- supportsProjectDefaults: scriptDefaultsSupported,
- project: selectedCheckout,
- },
- ]
- : [],
- );
- const t3File = useT3ProjectFileState(
- selectedCheckout.environmentId,
- selectedCheckout.workspaceRoot,
- );
- // What the "Default" option resolves to while no override is set: the
- // repo's t3.json value when present, otherwise the global setting.
- const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode;
- const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global";
- const importableScripts = useMemo(
- () =>
- t3File.scripts.filter(
- (fileScript) =>
- !scripts.some(
- (script) =>
- script.command === fileScript.command ||
- script.name.toLowerCase() === fileScript.name.toLowerCase(),
- ),
- ),
- [scripts, t3File.scripts],
- );
-
- const deleteScript = (scriptId: string) =>
- void persistScripts(
- (current) => current.filter((script) => script.id !== scriptId),
- scriptId,
- null,
- );
-
- const importFileScript = useCallback(
- async (fileScript: T3ProjectFileScript) => {
- const payload: NewProjectScriptInput = {
- name: fileScript.name,
- command: fileScript.command,
- icon: fileScript.icon ?? "play",
- runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
- keybinding: null,
- previewUrl: fileScript.previewUrl ?? null,
- autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
- };
- const result = await submitScript(null, payload);
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- setEditorRequest({
- scriptId: null,
- initial: payload,
- error: error instanceof Error ? error.message : "Failed to import action.",
- });
- }
- },
- [submitScript, setEditorRequest],
- );
-
- // ----- checkouts -----
- const updateGroupingPreference = useCallback(
- (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {
- const overrideKey = deriveProjectGroupingOverrideKey(member);
- const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides };
- if (selection === "inherit") {
- delete nextOverrides[overrideKey];
- } else {
- nextOverrides[overrideKey] = selection;
- }
- updateClientSettings({ sidebarProjectGroupingOverrides: nextOverrides });
- },
- [projectGroupingSettings.sidebarProjectGroupingOverrides, updateClientSettings],
- );
const removeMembers = useCallback(
async (members: ReadonlyArray) => {
@@ -937,23 +377,14 @@ function ProjectDetail({
draftStore.clearProjectDraftThreadId(projectRef);
}
- if (isWholeGroup) {
- if (hasOtherMembers) {
- void navigate({
- to: "/settings/projects",
- search: { project: group.projectKey, machine: undefined },
- replace: true,
- });
- } else {
- void navigate({ to: "/", replace: true });
- }
+ if (isWholeGroup && !hasOtherMembers) {
+ void navigate({ to: "/", replace: true });
}
},
[
deleteProject,
group.displayName,
group.memberProjects.length,
- group.projectKey,
hasOtherMembers,
navigate,
reportFailure,
@@ -961,26 +392,32 @@ function ProjectDetail({
],
);
- const selectedCheckoutGrouping =
- projectGroupingSettings.sidebarProjectGroupingOverrides?.[
- deriveProjectGroupingOverrideKey(selectedCheckout)
- ] ?? "inherit";
- const checkoutLabel = (member: SidebarProjectGroupMember) => {
- const label = member.environmentLabel ?? "This machine";
- return group.memberProjects.some(
- (other) =>
- other.physicalProjectKey !== member.physicalProjectKey &&
- (other.environmentLabel ?? "This machine") === label,
- )
- ? `${label} · ${member.workspaceRoot}`
- : label;
- };
- const selectedCheckoutLabel = checkoutLabel(selectedCheckout);
+ const checkoutChoices = (
+
+ {group.memberProjects.map((member) => (
+ void removeMembers([member])}
+ aria-label={`Remove checkout ${member.workspaceRoot}`}
+ >
+ Remove
+
+ }
+ />
+ ))}
+
+ );
return (
<>
-
+
member.faviconPath != null || member.projectIcon != null,
+ ) ? (
}
/>
- setProjectMergeMethod(null)}
- />
- ) : null
- }
- control={
-
- setProjectMergeMethod(
- value === "inherit" ? null : (value as PullRequestMergeMethod),
- )
- }
- >
-
-
- {projectMergeMethod === undefined
- ? "Last selected"
- : PULL_REQUEST_MERGE_METHOD_LABELS[projectMergeMethod]}
-
-
-
- Last selected
- {PULL_REQUEST_MERGE_METHOD_LABELS.merge}
- {PULL_REQUEST_MERGE_METHOD_LABELS.squash}
- {PULL_REQUEST_MERGE_METHOD_LABELS.rebase}
-
-
- }
- />
- member.defaultModelSelection !== null) ? (
- setDefaultModel(null)}
- />
- ) : null
- }
- control={
- resolvedSelection && activeEntry ? (
-
-
{
- void navigate({
- to: "/settings/providers",
- search: { environmentId: representative.environmentId, instanceId },
- });
- }}
- onInstanceModelChange={(instanceId, model) => {
- setDefaultModel(createModelSelection(instanceId, model));
- }}
- />
- {}}
- modelOptions={resolvedSelection.options ?? []}
- allowPromptInjectedEffort={false}
- planModeEnabled={projectSettings.planModeEnabled}
- triggerVariant="outline"
- triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
- onModelOptionsChange={(nextOptions) => {
- setDefaultModel(
- createModelSelection(
- resolvedSelection.instanceId,
- resolvedSelection.model,
- nextOptions,
- ),
- );
- }}
- />
-
- ) : (
- No providers available
- )
- }
- />
- member.defaultThreadEnvMode !== null) ? (
- setDefaultThreadEnvMode(null)}
- />
- ) : null
- }
- control={
- {
- if (value === "worktree" || value === "local") {
- setDefaultThreadEnvMode(value);
- } else if (value === "inherit") {
- setDefaultThreadEnvMode(null);
- }
- }}
- >
-
-
- {storedEnvMode === null
- ? group.memberProjects.length > 1
- ? "Default (per checkout)"
- : `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`
- : resolveEnvModeLabel(storedEnvMode)}
-
-
-
-
- {group.memberProjects.length > 1
- ? "Default (each checkout's t3.json or global setting)"
- : `Default (${inheritedEnvModeSource}: ${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`}
-
- {resolveEnvModeLabel("worktree")}
- {resolveEnvModeLabel("local")}
-
-
- }
- />
- void setAutoPull(undefined)}
- />
- ) : null
- }
- control={
- void setAutoPull(enabled)}
- />
- }
- />
- value !== undefined) ? (
- void setBrowserAccess(undefined)}
- />
- ) : null
- }
- control={
- {
- if (value === "inherit") void setBrowserAccess(undefined);
- else if (value === "enabled" || value === "disabled")
- void setBrowserAccess(value === "enabled");
- }}
- >
-
-
- {browserMixed
- ? "Mixed"
- : browserOverride === undefined
- ? `Inherit (${projectSettings.enableAgentBrowserAccess ? "on" : "off"})`
- : browserOverride
- ? "On"
- : "Off"}
-
-
-
- Inherit defaults
- On
- Off
-
-
- }
- />
-
-
- {hasMultipleCheckouts ? (
- {
- if (value) setSelectedCheckoutKey(value);
- }}
- >
-
- {selectedCheckoutLabel}
-
-
- {group.memberProjects.map((member) => (
-
-
- {checkoutLabel(member)}
-
-
- ))}
-
-
- }
- />
- ) : null}
- updateGroupingPreference(selectedCheckout, "inherit")}
- />
- ) : null
- }
- control={
- {
- if (
- value === "inherit" ||
- value === "repository" ||
- value === "repository_path" ||
- value === "separate"
- ) {
- updateGroupingPreference(selectedCheckout, value);
- }
- }}
- >
-
-
- {selectedCheckoutGrouping === "inherit"
- ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
- : PROJECT_GROUPING_MODE_LABELS[selectedCheckoutGrouping]}
-
-
-
-
- Use global default
-
-
- {PROJECT_GROUPING_MODE_LABELS.repository}
-
-
- {PROJECT_GROUPING_MODE_LABELS.repository_path}
-
-
- {PROJECT_GROUPING_MODE_LABELS.separate}
-
-
-
- }
- />
- {group.memberProjects.length > 1 ? (
- void removeMembers([selectedCheckout])}
- >
-
- Remove checkout
-
- }
- />
- ) : null}
-
-
-
Actions
-
- {!scriptDefaultsSupported
- ? `Saved and run only in ${selectedCheckoutLabel}.`
- : scriptsInherited
- ? "Inherited from machine defaults."
- : `Overridden for ${selectedCheckoutLabel}.`}
-
-
-
- {scriptDefaultsSupported && !scriptsInherited ? (
-
void persistScripts(() => null)}
- />
- ) : null}
- {importableScripts.length > 0 ? (
-
-
- }
- >
- Import scripts
-
-
-
-
- Import from t3.json
-
- Add actions declared by this checkout without editing them first.
-
-
-
- {importableScripts.map((fileScript) => (
- void importFileScript(fileScript)}
- >
-
-
-
{fileScript.name}
-
- {fileScript.command}
-
-
-
- ))}
-
-
- ) : null}
-
- setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })
- }
- >
-
- Add action
-
-
-
- setEditorRequest(editorRequestForScript(script, keybindings))}
- />
- {t3File.status === "invalid" ? (
-
- ) : null}
-
-
+
+ {hasMultipleCheckouts ? checkoutChoices : null}
- setEditorRequest(null)}
- />
;
- onChange: (value: string | null) => void;
-}) {
- const [query, setQuery] = useState("");
- const selected = options.find((option) => option.value === value);
- const allIcon =
- label === "project" ? : null;
- const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options];
- return (
- item.value === (value ?? "all")) ?? null}
- inputValue={query}
- onInputValueChange={setQuery}
- onOpenChange={() => setQuery("")}
- onValueChange={(next) => {
- if (next) onChange(next.value === "all" ? null : next.value);
- }}
- >
-
-
- {value === null ? allIcon : selected?.icon}
-
- {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)}
-
-
-
-
-
-
- No matching {label}s.
-
- {(item: (typeof items)[number]) => (
-
- {item.icon}
- {item.label}
-
- )}
-
-
-
- );
-}
+import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
+import { useSettingsScope } from "./SettingsScopeContext";
+import { SettingsScopeNotice } from "./SettingsScopeNotice";
-export function ProjectsSettings({
- projectKey,
- machineId,
- onScopeChange,
-}: {
- projectKey: string | null;
- machineId: string | null;
- onScopeChange: (project: string | null, machine: string | null) => void;
-}) {
- const groups = useSettingsProjectGroups();
- const { environments } = useEnvironments();
- const machine = environments.find((environment) => environment.environmentId === machineId);
- const machineOptions = environments.map((environment) => ({
- value: environment.environmentId,
- label: environment.label,
- icon: (
-
- ),
- }));
+/** Project identity and checkout management for the selected project. */
+export function ProjectsSettings() {
+ const { search: value, scope } = useSettingsScope();
+ // The panel follows remembered members when grouping replaces a project key.
+ const projectScope =
+ scope.kind === "project" ||
+ scope.kind === "checkout" ||
+ (scope.kind === "unavailable" &&
+ (scope.reason === "project-missing" || scope.reason === "checkout-missing"));
return (
-
-
-
- {environments.length > 3 ? (
-
onScopeChange(projectKey, value)}
- />
- ) : (
- {
- const value = next[0];
- if (value) onScopeChange(projectKey, value === "all" ? null : value);
- }}
- >
- All machines
- {machineOptions.map((option) => (
-
- {option.icon}
- {option.label}
-
- ))}
-
- )}
-
-
({
- value: group.projectKey,
- label: group.displayName,
- icon: ,
- }))}
- onChange={(value) => onScopeChange(value, machineId)}
- />
-
-
-
-
- {machineId !== null && !machine ? (
-
This machine is no longer available.
- ) : projectKey === null ? (
-
- ) : (
+ {value.project && projectScope ? (
+ ) : scope.kind === "unavailable" ? (
+
{scope.message}
+ ) : (
+
+ Choose a project to manage its name, icon, checkouts and actions.
+
)}
);
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx
index b6e853cba..f20412d84 100644
--- a/apps/web/src/components/settings/ProviderInstanceCard.tsx
+++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx
@@ -293,93 +293,98 @@ function ProviderEnvironmentSection(props: {
]);
return (
-
- {rows.map((variable, index) => (
-
- updateVariable(variable.id, { name: name.trim() })}
- placeholder="VARIABLE_NAME"
- spellCheck={false}
- aria-label={`Environment variable name ${index + 1}`}
- />
-
- =
-
- updateVariable(variable.id, { value })}
- type={variable.sensitive ? "password" : undefined}
- autoComplete="off"
- placeholder={
- variable.valueRedacted ? "Stored secret, enter a new value to replace" : "value"
- }
- spellCheck={false}
- aria-label={`Environment variable value ${index + 1}`}
- />
-
- {
- const sensitive = !variable.sensitive;
- updateVariable(variable.id, {
- sensitive,
- ...(sensitive && variable.valueRedacted === undefined
- ? {}
- : { valueRedacted: sensitive ? variable.valueRedacted : false }),
- });
- }}
- aria-pressed={variable.sensitive}
- aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`}
- >
- {variable.sensitive ? (
-
- ) : (
-
- )}
-
- }
- />
-
- {variable.sensitive ? "Sensitive, stored separately" : "Plain text"}
-
-
- removeVariable(variable.id)}
- aria-label={`Remove environment variable ${variable.name || index + 1}`}
- >
-
-
-
- ))}
-
- {rows.length > 0 ? (
-
- Sensitive values are stored separately and never returned to the app.
-
- ) : null}
-
+
Add variable
-
-
+ }
+ >
+ {rows.length > 0 ? (
+
+ {rows.map((variable, index) => (
+
+ updateVariable(variable.id, { name: name.trim() })}
+ placeholder="VARIABLE_NAME"
+ spellCheck={false}
+ aria-label={`Environment variable name ${index + 1}`}
+ />
+
+ =
+
+ updateVariable(variable.id, { value })}
+ type={variable.sensitive ? "password" : undefined}
+ autoComplete="off"
+ placeholder={
+ variable.valueRedacted ? "Stored secret, enter a new value to replace" : "value"
+ }
+ spellCheck={false}
+ aria-label={`Environment variable value ${index + 1}`}
+ />
+
+ {
+ const sensitive = !variable.sensitive;
+ updateVariable(variable.id, {
+ sensitive,
+ ...(sensitive && variable.valueRedacted === undefined
+ ? {}
+ : { valueRedacted: sensitive ? variable.valueRedacted : false }),
+ });
+ }}
+ aria-pressed={variable.sensitive}
+ aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`}
+ >
+ {variable.sensitive ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+
+ {variable.sensitive ? "Sensitive, stored separately" : "Plain text"}
+
+
+ removeVariable(variable.id)}
+ aria-label={`Remove environment variable ${variable.name || index + 1}`}
+ >
+
+
+
+ ))}
+
+ Sensitive values are stored separately and never returned to the app.
+
+
+ ) : null}
+
);
}
@@ -1277,7 +1282,7 @@ export function ProviderInstanceCard({
) : null}
- {setup ? {setup}
: null}
+ {setup ? {setup} : null}
-
-
-
+
{driverOption !== undefined ? (
@@ -1332,6 +1332,10 @@ export function ProviderInstanceCard({
className={writeBlocked ? "opacity-50 select-none" : undefined}
>
+
+ Favorites, visibility, and ordering are saved on this device. Custom models are saved
+ on the selected environment.
+
{
expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]);
});
});
+
+describe("nextHiddenModelsForBulkToggle", () => {
+ it("hides every built-in model without hiding custom models", () => {
+ const models = [model("a"), model("b"), model("custom", true)];
+
+ expect(nextHiddenModelsForBulkToggle(models, ["a"])).toEqual(["a", "b"]);
+ });
+
+ it("shows every built-in model while preserving unrelated hidden entries", () => {
+ const models = [model("a"), model("b"), model("custom", true)];
+
+ expect(nextHiddenModelsForBulkToggle(models, ["a", "b", "legacy", "custom"])).toEqual([
+ "legacy",
+ "custom",
+ ]);
+ });
+});
diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx
index 28af9d472..2540c23ff 100644
--- a/apps/web/src/components/settings/ProviderModelsSection.tsx
+++ b/apps/web/src/components/settings/ProviderModelsSection.tsx
@@ -96,6 +96,21 @@ export function groupModelsForDisplay<
];
}
+export function nextHiddenModelsForBulkToggle(
+ models: ReadonlyArray>,
+ hiddenModels: ReadonlyArray,
+): string[] {
+ const builtInSlugs = models.filter((model) => !model.isCustom).map((model) => model.slug);
+ const builtInSlugSet = new Set(builtInSlugs);
+ const allBuiltInModelsHidden = builtInSlugs.every((slug) => hiddenModels.includes(slug));
+
+ if (allBuiltInModelsHidden) {
+ return hiddenModels.filter((slug) => !builtInSlugSet.has(slug));
+ }
+
+ return [...new Set([...hiddenModels, ...builtInSlugs])];
+}
+
interface ProviderModelsSectionProps {
/** Identifier used to namespace input ids within the DOM. */
readonly instanceId: ProviderInstanceId;
@@ -181,6 +196,8 @@ export function ProviderModelsSection({
(model) => !model.isCustom && hiddenModelSet.has(model.slug),
).length;
const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]);
+ const allBuiltInModelsHidden =
+ builtInModels.length > 0 && builtInModels.every((model) => hiddenModelSet.has(model.slug));
const showFilter = models.length > FILTER_THRESHOLD;
const normalizedFilter = filter.trim().toLowerCase();
const isFiltering = showFilter && normalizedFilter.length > 0;
@@ -498,16 +515,44 @@ export function ProviderModelsSection({
onChange={(event) => setFilter(event.target.value)}
placeholder="Filter models"
size="sm"
- className="w-56"
+ className="w-56 max-w-full"
spellCheck={false}
aria-label="Filter models"
/>
) : null}
-
- {models.length} model{models.length === 1 ? "" : "s"}
- {favoriteCount > 0 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` : ""}
- {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""}
-
+
+ {builtInModels.length > 0 ? (
+
+ onHiddenModelsChange(nextHiddenModelsForBulkToggle(models, hiddenModels))
+ }
+ >
+ {allBuiltInModelsHidden ? "Enable all" : "Disable all"}
+
+ ) : null}
+
+ {models.length} model{models.length === 1 ? "" : "s"}
+ {favoriteCount > 0
+ ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}`
+ : ""}
+ {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""}
+
+
+ {driverKind !== "antigravity" && !isAdding ? (
+ setIsAdding(true)}
+ >
+
+ Add custom model
+
+ ) : null}
- ) : (
- setIsAdding(true)}
- >
-
- Add custom model
-
- )}
+ ) : null}
{driverKind !== "antigravity" && error ? (
{error}
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx
index b22085917..0070ade9a 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx
@@ -29,6 +29,7 @@ const settingsState = vi.hoisted(() => ({
readEnvironmentIds: [] as EnvironmentId[],
updateEnvironmentIds: [] as EnvironmentId[],
updateSettings: vi.fn(),
+ updateClientSettings: vi.fn(),
}));
const settingsSearchState = vi.hoisted(() => ({
@@ -81,14 +82,12 @@ vi.mock("../../state/use-atom-command", () => ({
}));
vi.mock("../../hooks/useSettings", () => ({
+ useUpdateClientSettings: () => settingsState.updateClientSettings,
useEnvironmentSettings: (environmentId: EnvironmentId) => {
settingsState.readEnvironmentIds.push(environmentId);
return settingsState.value;
},
- useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => {
- settingsState.updateEnvironmentIds.push(environmentId);
- return settingsState.updateSettings;
- },
+ useUpdateEnvironmentSettings: () => settingsState.updateSettings,
}));
vi.mock("../../environments/primary", () => ({
@@ -177,6 +176,7 @@ describe("EnvironmentProviderSettings routing", () => {
settingsState.readEnvironmentIds = [];
settingsState.updateEnvironmentIds = [];
settingsState.updateSettings.mockReset();
+ settingsState.updateClientSettings.mockReset();
settingsSearchState.targetId = null;
settingsSearchState.effects = [];
commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });
@@ -186,7 +186,6 @@ describe("EnvironmentProviderSettings routing", () => {
it("coalesces a nullable provider snapshot before rendering array-backed UI", () => {
expect(() => renderPanel()).not.toThrow();
expect(settingsState.readEnvironmentIds).toEqual([environmentId]);
- expect(settingsState.updateEnvironmentIds).toEqual([environmentId]);
});
it("routes refresh and provider update commands to the selected environment", async () => {
@@ -230,6 +229,30 @@ describe("EnvironmentProviderSettings routing", () => {
expect(editor?.props.instanceId).toBe(customId);
});
+ it.each([
+ ["onFavoriteModelsChange", { favorites: [{ provider: codexId, model: "chosen" }] }],
+ [
+ "onHiddenModelsChange",
+ { providerModelPreferences: { [codexId]: { hiddenModels: ["chosen"], modelOrder: [] } } },
+ ],
+ [
+ "onModelOrderChange",
+ { providerModelPreferences: { [codexId]: { hiddenModels: [], modelOrder: ["chosen"] } } },
+ ],
+ ])("saves %s on this device without changing the selected server", (action, expected) => {
+ atoms.providers = [provider()];
+ const panel = renderPanel();
+ const editor = visitElements(
+ panel,
+ (element) => element.props.instanceId === codexId && element.props.mode === "editor",
+ );
+ expect(editor).not.toBeNull();
+ if (!editor) throw new Error("Provider editor was not rendered");
+ (editor.props[action] as (models: string[]) => void)(["chosen"]);
+ expect(settingsState.updateClientSettings).toHaveBeenCalledExactlyOnceWith(expected);
+ expect(settingsState.updateSettings).not.toHaveBeenCalled();
+ });
+
it("does not substitute another account when the requested instance was removed", () => {
atoms.providers = [provider()];
const panel = renderPanel({ targetInstanceId: customId });
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
index 2433b0eee..6e0f4fd49 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
@@ -32,7 +32,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
import { isElectron } from "../../env";
import { usePrimarySessionState } from "../../environments/primary";
-import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
+import {
+ useEnvironmentSettings,
+ useUpdateClientSettings,
+ useUpdateEnvironmentSettings,
+} from "../../hooks/useSettings";
import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
import { cn } from "../../lib/utils";
import { resolveAppModelSelectionState } from "../../modelSelection";
@@ -267,6 +271,7 @@ function EnvironmentUnavailablePlaceholder({
interface ProviderSettingsTarget {
readonly environmentId?: EnvironmentId;
readonly instanceId?: ProviderInstanceId;
+ readonly scoped?: boolean;
}
export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
@@ -294,7 +299,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(
target.environmentId ?? primaryEnvironmentId,
);
- const { effectiveEnvironmentId, targetEnvironmentState } =
+ const { effectiveEnvironmentId: resolvedEnvironmentId, targetEnvironmentState } =
resolveProviderSettingsTargetEnvironment({
environments: options,
isReady,
@@ -303,6 +308,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
targetEnvironmentId: target.environmentId,
});
const targetEnvironmentMissing = targetEnvironmentState === "missing";
+ const effectiveEnvironmentId = target.scoped ? target.environmentId : resolvedEnvironmentId;
const selectedEnvironment =
options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
const selectedEnvironmentCanRenderSettings =
@@ -319,6 +325,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
)?.environmentId;
useEffect(() => {
if (
+ !target.scoped &&
(searchTargetId === searchableSetting("provider-health-check-interval").id ||
searchTargetId === searchableSetting("usage-providers").id) &&
!selectedEnvironmentCanRenderSettings &&
@@ -326,11 +333,16 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
) {
setSelectedEnvironmentId(searchableEnvironmentId);
}
- }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]);
+ }, [
+ searchTargetId,
+ searchableEnvironmentId,
+ selectedEnvironmentCanRenderSettings,
+ target.scoped,
+ ]);
const onlyPrimaryDevice =
options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
const deviceTabs =
- !onlyPrimaryDevice && options.length > 0 ? (
+ !target.scoped && !onlyPrimaryDevice && options.length > 0 ? (
rollbackBusyProviderInstanceIds(threadShells, environmentId),
[environmentId, threadShells],
);
+ const updateClientSettings = useUpdateClientSettings();
const serverProviders =
useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
@@ -868,7 +883,7 @@ export function EnvironmentProviderSettings({
const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];
const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];
const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);
- updateSettings({
+ updateClientSettings({
providerModelPreferences:
hiddenModels.length === 0 && modelOrder.length === 0
? rest
@@ -894,7 +909,7 @@ export function EnvironmentProviderSettings({
}),
),
];
- updateSettings({
+ updateClientSettings({
favorites: [
...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),
...favoriteModels.map((model) => ({ provider: instanceId, model })),
diff --git a/apps/web/src/components/settings/ProviderSetupSection.test.tsx b/apps/web/src/components/settings/ProviderSetupSection.test.tsx
index aea1238e4..199359da5 100644
--- a/apps/web/src/components/settings/ProviderSetupSection.test.tsx
+++ b/apps/web/src/components/settings/ProviderSetupSection.test.tsx
@@ -138,7 +138,9 @@ function renderSetup(
function button(view: unknown, label: string) {
return visitElements(
view,
- (element) => element.props.children === label && typeof element.props.onClick === "function",
+ (element) =>
+ (element.props.children === label || element.props["aria-label"] === label) &&
+ typeof element.props.onClick === "function",
);
}
diff --git a/apps/web/src/components/settings/ProviderSetupSection.tsx b/apps/web/src/components/settings/ProviderSetupSection.tsx
index bbc700762..0bbd160b4 100644
--- a/apps/web/src/components/settings/ProviderSetupSection.tsx
+++ b/apps/web/src/components/settings/ProviderSetupSection.tsx
@@ -12,6 +12,7 @@ import {
type ServerProvider,
} from "@t3tools/contracts";
import { useRef, useState } from "react";
+import { Trash2Icon } from "lucide-react";
import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
import { ensureLocalApi } from "../../localApi";
@@ -20,6 +21,8 @@ import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import { SettingsRow } from "./settingsLayout";
interface ProviderSetupSectionProps {
readonly environmentId: EnvironmentId;
@@ -68,24 +71,34 @@ export function readAntigravityAuthMethod(config: unknown): AntigravityAuthMetho
/** Setup state belongs to the selected environment and is never saved in client settings. */
export function ProviderSetupSection(props: ProviderSetupSectionProps) {
return (
-
- Antigravity runs on {props.environmentLabel}.
- {!props.enabled ? (
-
- Enable it to use it in threads.
- {!props.readOnly ? (
-
- Enable Antigravity
-
- ) : null}
-
- ) : null}
+
+
+
+ {props.environmentLabel}
+
+ {!props.enabled && !props.readOnly ? (
+
+ Enable Antigravity
+
+ ) : null}
+
+ }
+ />
{props.readOnly ? (
- This connection cannot change provider setup.
+
) : props.provider?.setup === undefined ? (
-
- Update this environment to install Antigravity and sign in with Google here.
-
+
) : (
(
label: string,
@@ -248,222 +277,257 @@ function ProviderSetupActions({
}
return (
-
-
-
Runtime
-
- {installation?.phase === "downloading"
- ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)} MB${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)} MB`}.`
- : installation?.phase === "extracting"
- ? "Extracting Antigravity."
- : installation?.phase === "verifying"
- ? "Checking the downloaded runtime."
- : installed
- ? "Antigravity is installed."
- : usesCustomBinary
- ? enabled
- ? "The configured Antigravity runtime is unavailable."
- : "The configured Antigravity runtime has not been checked."
- : "Install the official Antigravity runtime before signing in."}
-
- {installation?.phase === "downloading" &&
- installation.totalBytes !== null &&
- installation.totalBytes > 0 ? (
-
- ) : null}
- {installation?.message ? (
-
{installation.message}
- ) : null}
- {usesCustomBinary ? (
-
- This instance uses the binary path below. Installing a managed runtime does not change
- that path.
-
- ) : null}
- {!installed && !usesCustomBinary && !installActive && installation?.totalBytes ? (
-
- Downloads {Math.ceil(installation.totalBytes / 1_000_000)} MB from Google.
-
- ) : null}
- {!installed && !provider.setup?.canInstall ? (
-
- Automatic installation is unavailable here. Set an existing binary path below or use a
- supported remote environment.
-
- ) : null}
-
- {installActive && installation.operationId ? (
- {
- const operationId = installation.operationId;
- if (!operationId) return;
- void runCommand("Cancelling installation", () =>
- cancelInstall({ environmentId, input: { instanceId, operationId } }),
- );
- }}
- >
- Cancel installation
-
- ) : !installActive && provider.setup?.canInstall ? (
- void runCommand("Starting installation", () => startInstall(target))}
- >
- {installation?.installedVersion
- ? installation.version && installation.version !== installation.installedVersion
- ? "Update Antigravity"
- : "Reinstall Antigravity"
- : installation?.phase === "failed" || installation?.phase === "cancelled"
- ? "Retry installation"
- : installed
- ? "Install managed runtime"
- : "Install Antigravity"}
-
- ) : null}
- {installation?.canRemove && !installActive ? (
- void removeRuntime()}
- >
- Remove downloaded runtime
-
- ) : null}
-
-
+
+
+ {usesCustomBinary ? (
+
+ Uses the custom binary path below. Installation keeps that path.
+
+ ) : null}
+ {!installed && !provider.setup?.canInstall ? (
+
+ Automatic installation unavailable. Set a binary path or use another environment.
+
+ ) : null}
+
+ }
+ control={
+
+
+ {installationStatusMessage}
+
+
+ {installation?.phase === "downloading" &&
+ installation.totalBytes !== null &&
+ installation.totalBytes > 0 ? (
+
+ ) : null}
+
+ {!installActive &&
+ installation?.message &&
+ installation.message !== installationStatusMessage ? (
+
+ {installation.message}
+
+ ) : null}
+
+
+ {installActive && installation.operationId ? (
+ {
+ const operationId = installation.operationId;
+ if (!operationId) return;
+ void runCommand("Cancelling installation", () =>
+ cancelInstall({ environmentId, input: { instanceId, operationId } }),
+ );
+ }}
+ >
+ Cancel installation
+
+ ) : !installActive && provider.setup?.canInstall ? (
+
+ void runCommand("Starting installation", () => startInstall(target))
+ }
+ >
+ {installation?.installedVersion
+ ? installation.version &&
+ installation.version !== installation.installedVersion
+ ? "Update Antigravity"
+ : "Reinstall Antigravity"
+ : installation?.phase === "failed" || installation?.phase === "cancelled"
+ ? "Retry installation"
+ : installed
+ ? "Install managed runtime"
+ : "Install Antigravity"}
+
+ ) : null}
+
+ {installation?.canRemove && !installActive ? (
+
+ void removeRuntime()}
+ />
+ }
+ >
+
+
+ Remove downloaded runtime
+
+ ) : null}
+
+
+ }
+ />
-
-
{methodLabel}
-
- {authStatusMessage}
-
- {authorizationUrl ? (
- <>
-
-
void openSignInPage()}>
- Open sign-in page
-
-
void copySignInLink()}>
- {copiedFlowId === auth?.flowId ? "Link copied" : "Copy sign-in link"}
-
+
+
+ {authStatusMessage}
+
+ {authorizationUrl ? (
+
+ void openSignInPage()}>
+ Open sign-in page
+
+ void copySignInLink()}>
+ {copiedFlowId === auth?.flowId ? "Link copied" : "Copy sign-in link"}
+
+
+ ) : null}
+
+ {authActive && auth?.flowId ? (
+ {
+ const flowId = auth.flowId;
+ if (!flowId) return;
+ void runCommand("Cancelling sign-in", () =>
+ cancelAuth({ environmentId, input: { instanceId, flowId } }),
+ );
+ }}
+ >
+ Cancel sign-in
+
+ ) : !authActive && !authenticated && provider.setup?.canAuthenticate ? (
+ void runCommand("Starting sign-in", () => startAuth(target))}
+ >
+ {usesBrowser
+ ? auth?.phase === "failed" || auth?.phase === "cancelled"
+ ? "Retry Google sign-in"
+ : "Sign in with Google"
+ : auth?.phase === "failed" || auth?.phase === "cancelled"
+ ? "Retry connection"
+ : "Connect"}
+
+ ) : null}
+ {!authActive && provider.setup?.canAuthenticate ? (
+ void signOut()}
+ >
+ {usesBrowser ? "Sign out of Google" : "Disconnect"}
+
+ ) : null}
- {auth?.expiresAt ? (
+
+ }
+ >
+ {authorizationUrl || auth?.phase === "waiting" ? (
+
+ {authorizationUrl ? (
+ <>
+ {auth?.expiresAt ? (
+
+ Link expires at{" "}
+
+ {new Date(auth.expiresAt).toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ })}
+
+ .
+
+ ) : null}
+
{
+ event.preventDefault();
+ void submitCallback();
+ }}
+ >
+
+ If the final localhost page does not load, paste its full URL here.
+
+
+ setCallbackDraft({ flowId: auth?.flowId ?? null, value: event.target.value })
+ }
+ />
+
+ Continue
+
+
+ >
+ ) : auth?.phase === "waiting" ? (
- Link expires at{" "}
-
- {new Date(auth.expiresAt).toLocaleTimeString([], {
- hour: "numeric",
- minute: "2-digit",
- })}
-
- .
+ Sign-in is open in another client. Complete or cancel it there.
) : null}
-
{
- event.preventDefault();
- void submitCallback();
- }}
- >
-
- If the final localhost page does not load, paste its full URL here.
-
-
- setCallbackDraft({ flowId: auth?.flowId ?? null, value: event.target.value })
- }
- />
-
- Continue
-
-
- >
- ) : auth?.phase === "waiting" ? (
-
- Sign-in is open in another client. Complete or cancel it there.
-
+
) : null}
-
- {authActive && auth?.flowId ? (
- {
- const flowId = auth.flowId;
- if (!flowId) return;
- void runCommand("Cancelling sign-in", () =>
- cancelAuth({ environmentId, input: { instanceId, flowId } }),
- );
- }}
- >
- Cancel sign-in
-
- ) : !authActive && !authenticated && provider.setup?.canAuthenticate ? (
- void runCommand("Starting sign-in", () => startAuth(target))}
- >
- {usesBrowser
- ? auth?.phase === "failed" || auth?.phase === "cancelled"
- ? "Retry Google sign-in"
- : "Sign in with Google"
- : auth?.phase === "failed" || auth?.phase === "cancelled"
- ? "Retry connection"
- : "Connect"}
-
- ) : null}
- {!authActive && provider.setup?.canAuthenticate ? (
- void signOut()}
- >
- {usesBrowser ? "Sign out of Google" : "Disconnect"}
-
- ) : null}
-
-
+
- {pendingLabel ?
{pendingLabel}.
: null}
+
+ {pendingLabel ? `${pendingLabel}.` : null}
+
{error || queryError ? (
-
+
{error ?? queryError}
{queryError ? (
{
diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx
index 003e46869..b52e54bb5 100644
--- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx
+++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import type {
BackgroundBooleanState,
+ EnvironmentId,
ResourceAttributionEntry,
ResourceTelemetryAggregate,
ResourceTelemetryHistoryBucket,
@@ -26,7 +27,7 @@ import type {
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Option from "effect/Option";
-import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
@@ -38,7 +39,6 @@ import {
} from "../../lib/resourceTelemetryState";
import { cn } from "../../lib/utils";
import { ensureLocalApi } from "../../localApi";
-import { usePrimaryEnvironment } from "../../state/environments";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { formatRelativeTime } from "../../timestampFormat";
@@ -831,31 +831,43 @@ function AttributionTable({ entries }: { entries: ReadonlyArray option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];
- const telemetry = useResourceTelemetry();
+ const telemetry = useResourceTelemetry(environmentId);
const retryTelemetry = telemetry.retry;
- const history = useResourceTelemetryHistory({
- windowMs: selectedWindow.windowMs,
- bucketMs: selectedWindow.bucketMs,
- });
- const primaryEnvironment = usePrimaryEnvironment();
+ const history = useResourceTelemetryHistory(
+ {
+ windowMs: selectedWindow.windowMs,
+ bucketMs: selectedWindow.bucketMs,
+ },
+ environmentId,
+ );
const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
reportFailure: false,
});
const [signalingKeys, setSignalingKeys] = useState>(() => new Set());
const signalingKeysRef = useRef>(new Set());
- signalingKeysRef.current = signalingKeys;
- const primaryEnvironmentIdRef = useRef(primaryEnvironment?.environmentId);
- primaryEnvironmentIdRef.current = primaryEnvironment?.environmentId;
+ const environmentIdRef = useRef(environmentId);
+ useEffect(() => {
+ environmentIdRef.current = environmentId;
+ return () => {
+ environmentIdRef.current = null;
+ };
+ }, [environmentId]);
const [isRetrying, setIsRetrying] = useState(false);
const snapshot = telemetry.data;
const allT3 = snapshot?.groups.allT3;
const signalProcess = useCallback(
async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
+ const targetEnvironmentId = environmentIdRef.current;
+ if (targetEnvironmentId === null) return;
const identityKey = processIdentityKey(process);
if (signalingKeysRef.current.has(identityKey)) return;
const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey);
@@ -889,13 +901,12 @@ export function ResourceTelemetryDiagnostics() {
return;
}
}
- const environmentId = primaryEnvironmentIdRef.current;
- if (environmentId === undefined) {
+ if (environmentIdRef.current !== targetEnvironmentId) {
clearSignaling();
return;
}
void signalServerProcess({
- environmentId,
+ environmentId: targetEnvironmentId,
input: {
pid: process.identity.pid,
startTimeMs: process.identity.startTimeMs,
diff --git a/apps/web/src/components/settings/ScopedSwitch.tsx b/apps/web/src/components/settings/ScopedSwitch.tsx
new file mode 100644
index 000000000..06c798f89
--- /dev/null
+++ b/apps/web/src/components/settings/ScopedSwitch.tsx
@@ -0,0 +1,19 @@
+import type { ServerSettings } from "@t3tools/contracts";
+import type { ComponentProps } from "react";
+
+import { Switch } from "../ui/switch";
+import { useScopedSettingsMixed } from "./useScopedSettings";
+
+/**
+ * A switch for a server setting that renders the mixed state when the
+ * selected targets disagree on `settingKeys`. Clicking a mixed switch turns
+ * it on everywhere, the macOS mixed-checkbox convention.
+ */
+export function ScopedSwitch({
+ settingKeys,
+ checked,
+ ...props
+}: ComponentProps & { settingKeys: readonly (keyof ServerSettings)[] }) {
+ const mixed = useScopedSettingsMixed(settingKeys);
+ return ;
+}
diff --git a/apps/web/src/components/settings/SettingInheritance.test.ts b/apps/web/src/components/settings/SettingInheritance.test.ts
new file mode 100644
index 000000000..384dc353c
--- /dev/null
+++ b/apps/web/src/components/settings/SettingInheritance.test.ts
@@ -0,0 +1,57 @@
+import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
+import { describe, expect, it } from "vite-plus/test";
+
+import { settingInheritanceLayers } from "./SettingInheritance";
+
+const environmentId = EnvironmentId.make("laptop");
+const projectId = ProjectId.make("project");
+
+describe("settingInheritanceLayers", () => {
+ it("marks the built-in default effective when nothing is set", () => {
+ const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null);
+ const layers = settingInheritanceLayers(
+ { environmentId, label: "Laptop", projectId: null, ...resolved },
+ DEFAULT_SERVER_SETTINGS,
+ "defaultAutoPull",
+ );
+ expect(layers.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([
+ ["Laptop", "Inherits", false],
+ ["Default", "Off", true],
+ ]);
+ });
+
+ it("walks project override, environment value, then built-in default", () => {
+ const settings = {
+ ...DEFAULT_SERVER_SETTINGS,
+ defaultAutoPull: true,
+ projectSettingsOverrides: { [projectId]: { defaultAutoPull: false } },
+ };
+ const resolved = resolveProjectSettings(settings, projectId);
+ const layers = settingInheritanceLayers(
+ { environmentId, label: "Laptop", projectId, ...resolved },
+ settings,
+ "defaultAutoPull",
+ );
+ expect(layers.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([
+ ["Project", "Off", true],
+ ["Laptop", "On", false],
+ ["Default", "Off", false],
+ ]);
+ const inherited = settingInheritanceLayers(
+ {
+ environmentId,
+ label: "Laptop",
+ projectId,
+ ...resolveProjectSettings(settings, ProjectId.make("other")),
+ },
+ settings,
+ "defaultAutoPull",
+ );
+ expect(inherited.map((layer) => [layer.value, layer.effective])).toEqual([
+ ["Inherits", false],
+ ["On", true],
+ ["Off", false],
+ ]);
+ });
+});
diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx
new file mode 100644
index 000000000..d84d074ba
--- /dev/null
+++ b/apps/web/src/components/settings/SettingInheritance.tsx
@@ -0,0 +1,305 @@
+import {
+ DEFAULT_SERVER_SETTINGS,
+ resolveEnvironmentMachineKind,
+ type ServerSettings,
+} from "@t3tools/contracts";
+import { CheckIcon, LayersIcon } from "lucide-react";
+import * as Equal from "effect/Equal";
+
+import { cn } from "../../lib/utils";
+import type { EnvironmentPresentation } from "../../state/environments";
+import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
+import { resolveEnvModeLabel } from "../BranchToolbar.logic";
+import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
+import { Button } from "../ui/button";
+import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSettings";
+import { isProjectScopedSettingKey } from "./scopedSettings";
+
+interface InheritanceLayer {
+ readonly key: "project" | "environment" | "built-in";
+ readonly label: string;
+ readonly value: string;
+ readonly effective: boolean;
+ readonly set: boolean;
+}
+
+const WRITING_STYLE_LABELS: Record = {
+ repo_conventions: "Repository conventions",
+ conventional_commits: "Conventional Commits",
+ custom: "Custom instructions",
+};
+
+/** Human labels for the values the chain can show; falls back to a type summary. */
+function formatValue(key: keyof ServerSettings, value: unknown): string {
+ if (value === null || value === undefined) {
+ return key === "pullRequestMergeMethod"
+ ? "Last selected"
+ : key === "sidebarAutoSettleAfterDays"
+ ? "Never"
+ : key === "defaultModelSelection"
+ ? "Automatic"
+ : key === "sourceControlWriterModelSelection"
+ ? "Text generation model"
+ : "Not set";
+ }
+ if (typeof value === "boolean") return value ? "On" : "Off";
+ if (typeof value === "number") {
+ return key === "sidebarAutoSettleAfterDays"
+ ? `${value} ${value === 1 ? "day" : "days"}`
+ : String(value);
+ }
+ if (typeof value === "string") {
+ if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) {
+ return resolveEnvModeLabel(value);
+ }
+ if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) {
+ return PULL_REQUEST_MERGE_METHOD_LABELS[
+ value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS
+ ];
+ }
+ return value === "" ? "Empty" : value;
+ }
+ if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`;
+ if (typeof value === "object") {
+ if ("model" in value && typeof value.model === "string") return value.model;
+ if ("mode" in value && typeof value.mode === "string") {
+ return WRITING_STYLE_LABELS[value.mode] ?? value.mode;
+ }
+ }
+ return "Custom";
+}
+
+/**
+ * The layers a setting resolves through for one target, top-down: the
+ * project override when the target is a project, the environment's value,
+ * and the built-in default. The first layer that is set wins.
+ */
+export function settingInheritanceLayers(
+ target: ScopedSettingsTarget,
+ environmentSettings: ServerSettings,
+ key: keyof ServerSettings,
+): readonly InheritanceLayer[] {
+ const builtIn = DEFAULT_SERVER_SETTINGS[key];
+ const environmentValue = environmentSettings[key];
+ const projectSource = isProjectScopedSettingKey(key) ? target.sources[key] : "environment";
+ const environmentSet = !Equal.equals(environmentValue, builtIn);
+ const layers: InheritanceLayer[] = [];
+ if (target.projectId !== null && isProjectScopedSettingKey(key)) {
+ layers.push({
+ key: "project",
+ label: "Project",
+ value: projectSource === "project" ? formatValue(key, target.settings[key]) : "Inherits",
+ effective: projectSource === "project",
+ set: projectSource === "project",
+ });
+ }
+ layers.push({
+ key: "environment",
+ label: target.label,
+ value: environmentSet ? formatValue(key, environmentValue) : "Inherits",
+ effective: projectSource !== "project" && environmentSet,
+ set: environmentSet,
+ });
+ layers.push({
+ key: "built-in",
+ label: "Default",
+ value: formatValue(key, builtIn),
+ effective: projectSource !== "project" && !environmentSet,
+ set: true,
+ });
+ return layers;
+}
+
+export type SettingInheritanceState =
+ | "default"
+ | "environment"
+ | "inherited"
+ | "overridden"
+ | "mixed";
+
+/**
+ * A small indicator beside a row's title that opens a top-down view of where
+ * the setting's value comes from on each selected target. It sits inline so
+ * narrowing to a project does not add a caption line to every row.
+ */
+export interface SettingOverridingProject extends ProjectOverrideEntry {
+ readonly label: string;
+ /** Jumps the breadcrumb to this project so its override can be edited. */
+ readonly open: () => void;
+}
+
+const NO_OVERRIDING_PROJECTS: readonly SettingOverridingProject[] = [];
+
+export function SettingInheritance({
+ state,
+ summary,
+ targets,
+ environments,
+ keys,
+ overridingProjects = NO_OVERRIDING_PROJECTS,
+ onClearOverrides,
+}: {
+ state: SettingInheritanceState;
+ summary: string;
+ targets: readonly ScopedSettingsTarget[];
+ environments: readonly Pick[];
+ keys: readonly (keyof ServerSettings)[];
+ /** At environment scope: projects whose own value hides the environment's. */
+ overridingProjects?: readonly SettingOverridingProject[];
+ onClearOverrides?: (entries: readonly ProjectOverrideEntry[]) => void;
+}) {
+ const key = keys[0];
+ if (!key || targets.length === 0) return null;
+ const overrideSummary =
+ overridingProjects.length > 0
+ ? `${summary} · ${overridingProjects.length} project ${overridingProjects.length === 1 ? "override" : "overrides"}`
+ : summary;
+ const chains = targets.flatMap((target) => {
+ const environment = environments.find(
+ (candidate) => candidate.environmentId === target.environmentId,
+ );
+ if (!environment?.serverConfig) return [];
+ return [
+ {
+ target,
+ environment: { ...environment, serverConfig: environment.serverConfig },
+ machine: resolveEnvironmentMachineKind(environment.serverConfig),
+ layers: settingInheritanceLayers(target, environment.serverConfig.settings, key),
+ },
+ ];
+ });
+ return (
+
+
+
+ }
+ />
+ }
+ >
+
+
+ {overrideSummary}
+
+
+
+ {chains.map(({ target, environment, machine, layers }) => (
+
+
+
+ {target.label}
+
+
+ {layers.map((layer) => (
+
+
+ {layer.key === "environment" ? "Environment" : layer.label}
+
+
+ {layer.value}
+ {layer.effective ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ {(() => {
+ const overriding = overridingProjects.filter(
+ (project) => project.environmentId === target.environmentId,
+ );
+ if (overriding.length === 0) return null;
+ const overrides = environment.serverConfig.settings.projectSettingsOverrides;
+ return (
+
+
+ Overridden by
+ {onClearOverrides ? (
+ onClearOverrides(overriding)}
+ >
+ Reset {overriding.length === 1 ? "it" : "all"}
+
+ ) : null}
+
+
+ {overriding.map((project) => (
+
+
+ {project.label}
+
+
+ {isProjectScopedSettingKey(key)
+ ? formatValue(key, overrides[project.projectId]?.[key])
+ : null}
+
+
+ ))}
+
+
+ );
+ })()}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx
index b23e9d71f..a21b58d03 100644
--- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx
+++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx
@@ -1,9 +1,37 @@
+import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
+import { LayersIcon } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
+import type { EnvironmentPresentation } from "../../state/environments";
+import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
+import { ProjectFavicon } from "../ProjectFavicon";
+import {
+ Menu,
+ MenuPopup,
+ MenuRadioGroup,
+ MenuRadioItem,
+ MenuRadioItemIndicator,
+ MenuSeparator,
+ MenuTrigger,
+} from "../ui/menu";
import {
WorkspaceBreadcrumb,
WorkspaceBreadcrumbItem,
WorkspaceBreadcrumbSeparator,
} from "../WorkspaceBreadcrumb";
import { SETTINGS_SECTION_LABELS } from "./settingsSearch";
+import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
+import {
+ ALL_ENVIRONMENTS_VALUE,
+ ALL_PROJECTS_VALUE,
+ environmentAxisValue,
+ projectAxisValue,
+ selectEnvironmentAxis,
+ selectProjectAxis,
+ settingsScopeEnvironmentLabel,
+} from "./settingsScopeAxis";
const SETTINGS_BREADCRUMB_LABELS: Readonly> = {
...SETTINGS_SECTION_LABELS,
@@ -16,7 +44,27 @@ function settingsBreadcrumbLabel(pathname: string): string | null {
return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null;
}
-export function SettingsBreadcrumb({ pathname }: { pathname: string }) {
+export interface SettingsScopeBreadcrumbProps {
+ readonly value: SettingsScopeSearch;
+ readonly groups: readonly SidebarProjectSnapshot[];
+ readonly environments: readonly EnvironmentPresentation[];
+ readonly onChange: (next: SettingsScopeSearch) => void;
+}
+
+/**
+ * `Settings / Section / Environment / Project`. The last two crumbs are the
+ * targets a change applies to and read like the usage page's filter: muted at
+ * "all", foreground once narrowed. A project is the same project on every
+ * environment, so the environment crumb alone decides where a project
+ * override is written.
+ */
+export function SettingsBreadcrumb({
+ pathname,
+ scope,
+}: {
+ pathname: string;
+ scope?: SettingsScopeBreadcrumbProps | undefined;
+}) {
const sectionLabel = settingsBreadcrumbLabel(pathname);
return (
@@ -30,6 +78,158 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) {
{sectionLabel ?? "Settings"}
+ {scope ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : null}
);
}
+
+function ScopeMenu({
+ ariaLabel,
+ icon,
+ label,
+ narrowed,
+ children,
+}: {
+ ariaLabel: string;
+ icon: ReactNode;
+ label: string;
+ narrowed: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+function EnvironmentScopeMenu({
+ value,
+ groups,
+ environments,
+ onChange,
+}: SettingsScopeBreadcrumbProps) {
+ const resolved = resolveSettingsScope(value, groups, environments);
+ const environmentValue = environmentAxisValue(
+ value,
+ resolved.kind === "checkout" ? resolved.environmentId : null,
+ );
+ const selected = environments.find(
+ (environment) => environment.environmentId === environmentValue,
+ );
+ return (
+
+ ) : null
+ }
+ label={
+ selected
+ ? settingsScopeEnvironmentLabel(selected, environments)
+ : environmentValue !== ALL_ENVIRONMENTS_VALUE
+ ? "Unavailable environment"
+ : "All environments"
+ }
+ >
+ {
+ if (typeof next === "string") onChange(selectEnvironmentAxis(value, next));
+ }}
+ >
+
+
+
+ All environments
+
+
+
+
+ {environments.map((environment) => (
+
+
+
+
+ {settingsScopeEnvironmentLabel(environment, environments)}
+
+ {environment.connection.phase === "connected" ? null : (
+ Offline
+ )}
+
+
+
+ ))}
+
+
+ );
+}
+
+function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeBreadcrumbProps) {
+ const selected = groups.find((group) => group.projectKey === value.project);
+ return (
+ : null}
+ label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")}
+ >
+ {
+ if (typeof next === "string") onChange(selectProjectAxis(value, next));
+ }}
+ >
+
+
+ All projects
+
+
+
+
+ {groups.map((group) => (
+
+
+
+ {group.displayName}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 9c89e7d14..41c4488e3 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1,9 +1,9 @@
+import { ProjectActionsSettings } from "./ProjectActionsSettings";
import { Spinner } from "~/components/ui/spinner";
import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react";
import { Link, useNavigate } from "@tanstack/react-router";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useAtomValue } from "@effect/atom-react";
import {
type BackgroundActivityProfile,
type DesktopUpdateChannel,
@@ -75,14 +75,20 @@ import {
useTheme,
} from "../../hooks/useTheme";
import { useLocalStorage } from "../../hooks/useLocalStorage";
-import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
+import {
+ useScopedSettings,
+ useScopedSettingsMixed,
+ useUpdateScopedSettings,
+} from "./useScopedSettings";
+import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
+import { useSettingsScope } from "./SettingsScopeContext";
+import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
import {
getBackgroundTextGenerationProviders,
getCustomModelOptionsByInstance,
resolveAppModelSelectionState,
- withoutPlanAgentSelection,
} from "../../modelSelection";
import {
applyProviderInstanceSettings,
@@ -91,13 +97,7 @@ import {
} from "../../providerInstances";
import { ensureLocalApi, readLocalApi } from "../../localApi";
import { isMacPlatform } from "../../lib/utils";
-import {
- primaryServerConfigAtom,
- primaryServerObservabilityAtom,
- primaryServerProvidersAtom,
-} from "../../state/server";
-import { useProjects } from "../../state/entities";
-import { usePrimaryEnvironmentId } from "../../state/environments";
+import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
import { formatRelativeTimeLabel } from "../../timestampFormat";
import { Button } from "../ui/button";
@@ -124,7 +124,6 @@ import {
TYPOGRAPHY_ADVANCED_STORAGE_KEY,
} from "../../appearanceFonts";
import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
-import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
import {
NumberField,
@@ -135,6 +134,7 @@ import {
} from "../ui/number-field";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { Switch } from "../ui/switch";
+import { ScopedSwitch } from "./ScopedSwitch";
import { stackedThreadToast, toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { ThemeLibrary } from "./ThemeSettings";
@@ -142,7 +142,6 @@ import {
backgroundActivityOverrideSettings,
backgroundActivitySharedPolicySettings,
durationToSeconds,
- formatDiagnosticsDescription,
getChangedBrowserSettingLabels,
getChangedTypographySettingLabels,
normalizeIntervalSeconds,
@@ -472,8 +471,8 @@ export function useSettingsRestore(onRestored?: () => void) {
clearThemeHalves,
themeHalves,
} = useTheme();
- const settings = usePrimarySettings();
- const updateSettings = useUpdatePrimarySettings();
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
const isTextGenerationModelDirty = !Equal.equals(
settings.textGenerationModelSelection ?? null,
@@ -777,8 +776,8 @@ function BackgroundActivityAdvancedDialog({
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
}) {
- const settings = usePrimarySettings();
- const updateSettings = useUpdatePrimarySettings();
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
const activeProfile = resolvedBackgroundActivity.profile;
const automaticGitFetchIntervalSeconds = durationToSeconds(
@@ -1055,8 +1054,8 @@ export function AppearanceSettingsPanel() {
} = useTheme();
const customThemes = useCustomThemes();
const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);
- const settings = usePrimarySettings();
- const updateSettings = useUpdatePrimarySettings();
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
const environmentStageLabel = useEnvironmentStageLabel();
const showEnvironmentIdentification =
resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;
@@ -1356,7 +1355,7 @@ function probeFontDefaultFamilies() {
}
function useFontDefaultFamilies() {
- const settings = usePrimarySettings();
+ const settings = useScopedSettings();
// An unset preference shows the font it resolves to on this machine, so the
// name is probed rather than hardcoded. Pylon's sans default is a bundled
// face, which loads asynchronously: a probe on first mount can miss it and
@@ -1384,8 +1383,8 @@ function useFontDefaultFamilies() {
}
function InterfaceFontRow({ preview }: { preview?: ReactNode }) {
- const settings = usePrimarySettings();
- const updateSettings = useUpdatePrimarySettings();
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
const defaults = useFontDefaultFamilies();
return (
} />
@@ -1939,8 +1938,8 @@ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([
* jump to one of the rows unfolds the section.
*/
function LegacyFeaturesSection() {
- const settings = usePrimarySettings();
- const updateSettings = useUpdatePrimarySettings();
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
const [open, setOpen] = useState(false);
const searchTargetId = useSettingsSearchTargetId();
const targetRef = useSettingsSearchTarget("legacy-features");
@@ -1978,29 +1977,7 @@ function LegacyFeaturesSection() {
{
- const planModeEnabled = Boolean(checked);
- const textGenerationModelSelection = withoutPlanAgentSelection(
- settings.textGenerationModelSelection,
- );
- const sourceControlWriterModelSelection = withoutPlanAgentSelection(
- settings.sourceControlWriterModelSelection,
- );
- updateSettings({
- planModeEnabled,
- ...(planModeEnabled
- ? {}
- : {
- ...(textGenerationModelSelection &&
- textGenerationModelSelection !== settings.textGenerationModelSelection
- ? { textGenerationModelSelection }
- : {}),
- ...(sourceControlWriterModelSelection &&
- sourceControlWriterModelSelection !==
- settings.sourceControlWriterModelSelection
- ? { sourceControlWriterModelSelection }
- : {}),
- }),
- });
+ updateSettings({ planModeEnabled: Boolean(checked) });
}}
aria-label="Plan mode (legacy)"
/>
@@ -2008,10 +1985,12 @@ function LegacyFeaturesSection() {
/>
{
if (!checked) {
@@ -2054,25 +2033,32 @@ function LegacyFeaturesSection() {
}
export function GeneralSettingsPanel() {
- const settings = usePrimarySettings();
- const updateSettings = useUpdatePrimarySettings();
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
const navigate = useNavigate();
- const environmentId = usePrimaryEnvironmentId();
+ const { scope, environment, connectedEnvironments } = useSettingsScope();
+ // The representative environment supplies the provider list for pickers;
+ // a fanned-out model choice is validated against every target before it
+ // is written. Per-machine tuning (background activity overrides) still
+ // needs exactly one environment.
+ const environmentId = environment?.environmentId ?? null;
+ const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null;
+ const hasServerTargets = connectedEnvironments.length > 0;
const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
const lastEnabledProjectGroupingMode = useRef(
readLastEnabledProjectGroupingMode(),
);
- const observability = useAtomValue(primaryServerObservabilityAtom);
- const serverProviders = useAtomValue(primaryServerProvidersAtom);
+ const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
const supportsAutoSettlement =
- useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true;
- const diagnosticsDescription = formatDiagnosticsDescription({
- localTracingEnabled: observability?.localTracingEnabled ?? false,
- otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
- otlpTracesUrl: observability?.otlpTracesUrl,
- otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false,
- otlpMetricsUrl: observability?.otlpMetricsUrl,
- });
+ connectedEnvironments.length > 0 &&
+ connectedEnvironments.every(
+ (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true,
+ );
+ const supportsRestartContinuation =
+ connectedEnvironments.length > 0 &&
+ connectedEnvironments.every(
+ (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true,
+ );
const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
const textGenInstanceId = textGenerationModelSelection.instanceId;
@@ -2085,6 +2071,9 @@ export function GeneralSettingsPanel() {
settings,
),
);
+ const hasTextGenerationProvider = textGenerationModelInstanceEntries.some(
+ (entry) => entry.enabled && entry.isAvailable,
+ );
const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
(entry) => entry.instanceId === textGenInstanceId,
);
@@ -2100,9 +2089,16 @@ export function GeneralSettingsPanel() {
settings.textGenerationModelSelection ?? null,
DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
);
+ const textGenerationModelDisabledReason = useScopedModelDisabledReason(
+ settings,
+ textGenerationModelInstanceEntries,
+ );
const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile;
const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings);
+ const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]);
+ const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]);
+ const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]);
const backgroundActivityDescription =
backgroundActivityProfileOption === "advanced"
? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${
@@ -2116,7 +2112,8 @@ export function GeneralSettingsPanel() {
return (
-
+
+ {scope.kind === "all" || scope.kind === "environment" ? : null}
updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
@@ -2186,6 +2185,7 @@ export function GeneralSettingsPanel() {
updateSettings({
@@ -2217,6 +2218,7 @@ export function GeneralSettingsPanel() {
{settings.sidebarAutoSettleAfterDays !== null ? (
updateSettings({ enableProviderUpdateChecks: Boolean(checked) })
@@ -2471,10 +2475,17 @@ export function GeneralSettingsPanel() {
@@ -2487,8 +2498,10 @@ export function GeneralSettingsPanel() {
) : null
}
control={
-
updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) })
}
@@ -2527,6 +2540,7 @@ export function GeneralSettingsPanel() {
@@ -2549,10 +2563,10 @@ export function GeneralSettingsPanel() {
control={
<>
{
if (value === "advanced") {
- setBackgroundActivityDialogOpen(true);
+ if (isEnvironmentScope) setBackgroundActivityDialogOpen(true);
return;
}
if (
@@ -2570,7 +2584,9 @@ export function GeneralSettingsPanel() {
aria-label="Background activity profile"
>
- {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[backgroundActivityProfileOption]}
+ {(value: BackgroundActivityProfileOption | null) =>
+ value === null ? "Mixed" : BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[value]
+ }
@@ -2583,12 +2599,14 @@ export function GeneralSettingsPanel() {
{BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]}
-
- {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced}
+
+ {isEnvironmentScope
+ ? BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced
+ : `${BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced} (one environment)`}
- {backgroundActivityProfileOption === "advanced" ? (
+ {backgroundActivityProfileOption === "advanced" && isEnvironmentScope ? (
) : null}
>
@@ -2615,24 +2633,9 @@ export function GeneralSettingsPanel() {
-
- }
- size="sm"
- variant="outline"
- >
- Project settings
-
- }
- />
-
updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) })
@@ -2661,6 +2665,7 @@ export function GeneralSettingsPanel() {
/>
updateSettings({ addProjectBaseDirectory: next })}
- placeholder="~/"
+ placeholder={mixedAddProjectBaseDirectory ? "Mixed" : "~/"}
spellCheck={false}
aria-label="Add project base directory"
/>
@@ -2815,10 +2820,11 @@ export function GeneralSettingsPanel() {
@@ -2831,72 +2837,94 @@ export function GeneralSettingsPanel() {
) : null
}
control={
-
-
{
- void navigate({
- to: "/settings/providers",
- search: { environmentId, instanceId },
- });
- },
+ !hasServerTargets ? (
+
+ Connect an environment to choose its text generation model.
+
+ ) : !hasTextGenerationProvider ? (
+
+ No text generation providers available.
+
+ ) : (
+
+
{
+ void navigate({
+ to: "/settings/providers",
+ search: { environmentId, instanceId },
+ });
+ },
+ }
+ : {})}
+ onInstanceModelChange={(instanceId, model) => {
+ const reason = textGenerationModelDisabledReason(instanceId, model);
+ if (reason) {
+ toastManager.add({
+ type: "error",
+ title: "Text generation model not saved",
+ description: reason,
+ });
+ return;
}
- : {})}
- onInstanceModelChange={(instanceId, model) => {
- updateSettings({
- textGenerationModelSelection: resolveAppModelSelectionState(
- {
- ...settings,
- textGenerationModelSelection: createModelSelection(instanceId, model),
- },
- serverProviders,
- ),
- });
- }}
- />
- {}}
- modelOptions={textGenModelOptions}
- allowPromptInjectedEffort={false}
- planModeEnabled={settings.planModeEnabled}
- capabilityContext="background-text-generation"
- triggerVariant="outline"
- triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
- onModelOptionsChange={(nextOptions) => {
- updateSettings({
- textGenerationModelSelection: resolveAppModelSelectionState(
- {
- ...settings,
- textGenerationModelSelection: createModelSelection(
- textGenInstanceId,
- textGenModel,
- nextOptions,
+ updateSettings({
+ textGenerationModelSelection: resolveAppModelSelectionState(
+ {
+ ...settings,
+ textGenerationModelSelection: createModelSelection(instanceId, model),
+ },
+ backgroundTextGenerationProviders,
+ ),
+ });
+ }}
+ />
+ {textGenInstanceEntry ? (
+ {}}
+ modelOptions={textGenModelOptions}
+ allowPromptInjectedEffort={false}
+ planModeEnabled={settings.planModeEnabled}
+ triggerVariant="outline"
+ triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
+ onModelOptionsChange={(nextOptions) => {
+ updateSettings({
+ textGenerationModelSelection: resolveAppModelSelectionState(
+ {
+ ...settings,
+ textGenerationModelSelection: createModelSelection(
+ textGenInstanceId,
+ textGenModel,
+ nextOptions,
+ ),
+ },
+ backgroundTextGenerationProviders,
),
- },
- serverProviders,
- ),
- });
- }}
- />
-
+ });
+ }}
+ />
+ ) : null}
+
+ )
}
/>
@@ -2910,11 +2938,23 @@ export function GeneralSettingsPanel() {
description="Current version of the application."
/>
)}
+
+
} size="sm" variant="outline">
+
+ }
+ size="sm"
+ variant="outline"
+ >
View diagnostics
}
@@ -2940,25 +2980,31 @@ export function GeneralSettingsPanel() {
}
export function ArchivedThreadsPanel() {
- const projects = useProjects();
+ const { scope } = useSettingsScope();
const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
- const environmentIds = useMemo(
- () => [...new Set(projects.map((project) => project.environmentId))],
- [projects],
- );
const {
snapshots: archivedSnapshots,
error: archiveError,
isLoading: isLoadingArchive,
refresh: refreshArchivedThreads,
- } = useArchivedThreadSnapshots(environmentIds);
+ } = useArchivedThreadSnapshots(scope.environmentIds);
const archivedGroups = useMemo(() => {
+ const selectedProjectKeys =
+ scope.kind === "project" || scope.kind === "checkout"
+ ? new Set(scope.members.map((member) => `${member.environmentId}:${member.id}`))
+ : null;
const projectsByEnvironmentAndId = new Map(
archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
- snapshot.projects.map(
- (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
- ),
+ snapshot.projects
+ .filter(
+ (project) =>
+ selectedProjectKeys === null ||
+ selectedProjectKeys.has(`${environmentId}:${project.id}`),
+ )
+ .map(
+ (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
+ ),
),
);
const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
@@ -2992,7 +3038,7 @@ export function ArchivedThreadsPanel() {
}
}
return groups;
- }, [archivedSnapshots]);
+ }, [archivedSnapshots, scope]);
const handleArchivedThreadContextMenu = useCallback(
async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
@@ -3074,7 +3120,7 @@ export function ArchivedThreadsPanel() {
) : (
archivedGroups.map(({ project, threads: projectThreads }, index) => (
}
diff --git a/apps/web/src/components/settings/SettingsScopeContext.tsx b/apps/web/src/components/settings/SettingsScopeContext.tsx
new file mode 100644
index 000000000..c758b2927
--- /dev/null
+++ b/apps/web/src/components/settings/SettingsScopeContext.tsx
@@ -0,0 +1,65 @@
+import { createContext, type ReactNode, useContext, useMemo } from "react";
+
+import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
+import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
+import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";
+import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
+
+function useResolvedSettingsScope(search: SettingsScopeSearch) {
+ const groups = useSettingsProjectGroups();
+ const { environments: availableEnvironments } = useEnvironments();
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ return useMemo(() => {
+ const scope = resolveSettingsScope(search, groups, availableEnvironments);
+ const selected = selectScopedSettingsEnvironments(
+ scope,
+ availableEnvironments,
+ primaryEnvironmentId,
+ );
+ const targets = resolveScopedSettingsTargets(scope, selected.connectedEnvironments);
+ // The representative target supplies display values; project scopes
+ // prefer the member on the primary environment, like environments do.
+ const target =
+ targets.find(
+ (candidate) => candidate.environmentId === selected.environment?.environmentId,
+ ) ??
+ targets[0] ??
+ null;
+ return { scope, groups, ...selected, targets, target };
+ }, [availableEnvironments, groups, primaryEnvironmentId, search]);
+}
+
+const SettingsScopeContext = createContext<
+ | (ReturnType & {
+ search: SettingsScopeSearch;
+ selectScope: (next: SettingsScopeSearch) => void;
+ })
+ | null
+>(null);
+
+export function SettingsScopeProvider({
+ search,
+ onChange,
+ children,
+}: {
+ search: SettingsScopeSearch;
+ onChange: (next: SettingsScopeSearch) => void;
+ children: ReactNode;
+}) {
+ const resolved = useResolvedSettingsScope(search);
+ const value = useMemo(
+ () => ({ ...resolved, search, selectScope: onChange }),
+ [onChange, resolved, search],
+ );
+ return {children} ;
+}
+
+export function useOptionalSettingsScope() {
+ return useContext(SettingsScopeContext);
+}
+
+export function useSettingsScope() {
+ const scope = useOptionalSettingsScope();
+ if (scope === null) throw new Error("Settings scope must be read inside SettingsScopeProvider.");
+ return scope;
+}
diff --git a/apps/web/src/components/settings/SettingsScopeNotice.tsx b/apps/web/src/components/settings/SettingsScopeNotice.tsx
new file mode 100644
index 000000000..1bf365b72
--- /dev/null
+++ b/apps/web/src/components/settings/SettingsScopeNotice.tsx
@@ -0,0 +1,90 @@
+import { Button } from "../ui/button";
+import { Alert, AlertAction, AlertDescription } from "../ui/alert";
+import { SettingsPageContainer } from "./settingsLayout";
+import { useSettingsScope } from "./SettingsScopeContext";
+import { useEnvironments } from "../../state/environments";
+import type { SettingsScopeSearch } from "./settingsScope";
+import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
+import { useLocation, useNavigate } from "@tanstack/react-router";
+import type { EnvironmentId } from "@t3tools/contracts";
+
+/** Offer an explicit target change when a category has no settings at this scope. */
+export function SettingsScopeNotice({
+ children,
+ target,
+ targetId,
+ eligibleEnvironmentIds,
+}: {
+ children: string;
+ target: "environment" | "all" | "project" | "checkout";
+ targetId?: string;
+ eligibleEnvironmentIds?: readonly EnvironmentId[];
+}) {
+ const { selectScope, search } = useSettingsScope();
+ const navigate = useNavigate({ from: "/settings" });
+ const pathname = useLocation({ select: (location) => location.pathname });
+ const { environments } = useEnvironments();
+ const groups = useSettingsProjectGroups();
+ const choices: { label: string; search: SettingsScopeSearch }[] =
+ target === "checkout"
+ ? groups
+ .filter((group) => !search.project || group.projectKey === search.project)
+ .flatMap((group) =>
+ group.memberProjects.map((member) => ({
+ label: `${group.displayName} · ${member.environmentLabel ?? "Environment"} · ${member.workspaceRoot}`,
+ search: {
+ project: group.projectKey,
+ machine: member.environmentId,
+ checkout: member.physicalProjectKey,
+ },
+ })),
+ )
+ : target === "project"
+ ? groups.map((group) => ({
+ label: group.displayName,
+ search: { project: group.projectKey },
+ }))
+ : target === "environment"
+ ? environments
+ .filter(
+ (entry) =>
+ eligibleEnvironmentIds === undefined ||
+ eligibleEnvironmentIds.includes(entry.environmentId),
+ )
+ .map((entry) => ({
+ label: environments.some(
+ (other) =>
+ other.environmentId !== entry.environmentId && other.label === entry.label,
+ )
+ ? `${entry.label} · ${entry.displayUrl || entry.environmentId}`
+ : entry.label,
+ search: { machine: entry.environmentId },
+ }))
+ : [{ label: "Open all environments", search: {} }];
+ return (
+
+
+
+ {children}
+
+ {choices.map((choice) => (
+ {
+ if (targetId)
+ void navigate({ to: pathname, search: () => choice.search, hash: targetId });
+ else selectScope(choice.search);
+ }}
+ >
+ {choice.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx
index cf2d0b83c..f215d6c25 100644
--- a/apps/web/src/components/settings/SettingsSidebarNav.tsx
+++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx
@@ -41,11 +41,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
import { scrollToSettingsTarget } from "./settingsLayout";
import {
searchSettings,
+ isSettingsOverviewVisible,
SETTINGS_SECTION_LABELS,
type SettingsPath,
type SettingsSearchItem,
} from "./settingsSearch";
import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
+import { validateSettingsScopeSearch } from "./settingsScope";
const SnapShotIcon = createLucideIcon("snap-shot", [
[
@@ -103,6 +105,11 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) {
export function SettingsSidebarNav({ pathname }: { pathname: string }) {
const navigate = useNavigate();
const currentHash = useLocation({ select: (location) => location.hash });
+ const currentSearch = useLocation({ select: (location) => location.search });
+ const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]);
+ const navItems = SETTINGS_NAV_ITEMS.filter(
+ (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),
+ );
const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
const searchInputRef = useRef(null);
const [query, setQuery] = useState("");
@@ -181,18 +188,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
setOpenMobile(false);
}
const targetId = item.targetId ?? item.id;
- if (
- item.to !== "/settings/projects" &&
- pathname === item.to &&
- currentHash.replace(/^#/, "") === targetId
- ) {
+ if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) {
scrollToSettingsTarget(targetId);
return;
}
void navigate({
to: item.to,
- search: (previous) =>
- item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
hash: targetId,
replace: true,
hashScrollIntoView: false,
@@ -319,7 +320,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
) : (
- {SETTINGS_NAV_ITEMS.map((item) => {
+ {navItems.map((item) => {
const Icon = item.icon;
const isGeneralDetailPage =
item.to === "/settings/general" && pathname === "/settings/open-source-licenses";
diff --git a/apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx b/apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx
deleted file mode 100644
index 2a804cf9a..000000000
--- a/apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { TriangleAlertIcon } from "lucide-react";
-
-import { useSharedSettingsSync } from "../../hooks/useSettings";
-import { Alert, AlertAction, AlertDescription } from "../ui/alert";
-import { Button } from "../ui/button";
-
-/**
- * Warns when a connected environment holds different shared settings than
- * the primary one, and offers to write the primary's values everywhere.
- * Renders nothing when every connected environment agrees.
- */
-export function SharedSettingsMismatchAlert() {
- const { mismatches, applyToAll } = useSharedSettingsSync();
- if (mismatches.length === 0) {
- return null;
- }
- const labels = mismatches.map((mismatch) => mismatch.label).join(", ");
- return (
-
-
-
- Settings differ on {labels}. Thread and source control preferences are meant to match on
- every environment.
-
-
-
- Apply to all
-
-
-
- );
-}
diff --git a/apps/web/src/components/settings/SnapShotSettings.test.tsx b/apps/web/src/components/settings/SnapShotSettings.test.tsx
index 8d34af17a..584c22449 100644
--- a/apps/web/src/components/settings/SnapShotSettings.test.tsx
+++ b/apps/web/src/components/settings/SnapShotSettings.test.tsx
@@ -26,6 +26,7 @@ vi.mock("react/compiler-runtime", async () => {
});
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
+vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));
const bridge = vi.hoisted(() => ({
getSnapShotState: vi.fn<() => Promise>(),
setSnapShotShortcutSuppressed: vi.fn(),
diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx
index 6d9d20105..2a4858ded 100644
--- a/apps/web/src/components/settings/SourceControlSettings.tsx
+++ b/apps/web/src/components/settings/SourceControlSettings.tsx
@@ -18,10 +18,10 @@ import {
resolveServerBackgroundActivitySettings,
} from "@t3tools/shared/backgroundActivitySettings";
-import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
-import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
+import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
+import { useSettingsScope } from "./SettingsScopeContext";
+import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
import { cn } from "../../lib/utils";
-import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
import { useEnvironmentQuery } from "../../state/query";
import { sourceControlEnvironment } from "../../state/sourceControl";
import { Badge } from "../ui/badge";
@@ -343,8 +343,8 @@ function DiscoveryItemRow({
}
function GitFetchIntervalSettings() {
- const settings = usePrimarySettings();
- const updateSettings = useUpdatePrimarySettings();
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
const automaticGitFetchIntervalSeconds = durationToSeconds(
resolvedBackgroundActivity.automaticGitFetchInterval,
@@ -498,15 +498,14 @@ function EmptySourceControlDiscovery({
}
export function SourceControlSettingsPanel() {
- const { environments } = useEnvironments();
- const primaryEnvironment = usePrimaryEnvironment();
- const fallbackEnvironment =
- environments.find((environment) => environment.connection.phase === "connected") ??
- environments[0] ??
- null;
+ const { scope, environment, connectedEnvironments } = useSettingsScope();
+ // Discovery scans one machine's tools, so it shows the representative
+ // environment (named in the section title when several are selected);
+ // the settings rows above it fan out like everywhere else.
const environmentId =
- primaryEnvironment?.environmentId ?? fallbackEnvironment?.environmentId ?? null;
- const isPrimaryEnvironment = environmentId === primaryEnvironment?.environmentId;
+ environment?.connection.phase === "connected" ? environment.environmentId : null;
+ const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1;
+ const environmentSuffix = aggregate && environment ? ` · ${environment.label}` : "";
const discovery = useEnvironmentQuery(
environmentId === null
? null
@@ -543,10 +542,19 @@ export function SourceControlSettingsPanel() {
return (
-
- {isInitialScanPending ? (
+
+ {environmentId === null ? (
+
+
+ Connect an environment to inspect its version control tools and hosting integrations.
+
+
+ ) : isInitialScanPending ? (
<>
-
+
>
) : hasDiscoveryItems ? (
@@ -554,14 +562,12 @@ export function SourceControlSettingsPanel() {
{hasVersionControlSystems ? (
{result.versionControlSystems.map((item) => (
- {item.kind === "git" && isPrimaryEnvironment ? (
-
- ) : undefined}
+ {item.kind === "git" ? : undefined}
))}
@@ -570,7 +576,11 @@ export function SourceControlSettingsPanel() {
{result.sourceControlProviders.length > 0 ? (
{result.sourceControlProviders.map((item) => (
@@ -587,8 +597,6 @@ export function SourceControlSettingsPanel() {
/>
)}
- {/* Its rows are serverScoped: without a primary they render inert with
- an explanation, which beats disappearing. */}
);
diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx
new file mode 100644
index 000000000..bfc75e083
--- /dev/null
+++ b/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx
@@ -0,0 +1,221 @@
+import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
+import { act, StrictMode, type ReactNode } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import type { ScopedSettingsPatch } from "./scopedSettings";
+
+type WritingStyle = typeof DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
+const state = vi.hoisted(() => ({
+ styles: [] as WritingStyle[],
+ updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(),
+}));
+
+vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
+vi.mock("./useScopedSettings", () => ({
+ useScopedSettings: () => ({
+ ...DEFAULT_UNIFIED_SETTINGS,
+ sourceControlWritingStyle: state.styles[0],
+ }),
+ useScopedSettingsMixed: () => JSON.stringify(state.styles[0]) !== JSON.stringify(state.styles[1]),
+ useUpdateScopedSettings: () => state.updateSettings,
+}));
+vi.mock("./SettingsScopeContext", () => ({
+ useSettingsScope: () => ({
+ scope: { kind: "all", environmentIds: [] },
+ environment: null,
+ connectedEnvironments: [],
+ targets: state.styles.map((style) => ({
+ settings: { ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: style },
+ })),
+ }),
+}));
+vi.mock("./useScopedModelAvailability", () => ({
+ useScopedModelDisabledReason: () => () => null,
+}));
+vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
+vi.mock("../../state/server", () => ({ EMPTY_SERVER_PROVIDERS: [] }));
+vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: () => null }));
+vi.mock("./settingsSearch", () => ({ searchableSetting: (id: string) => ({ id, title: id }) }));
+vi.mock("./settingsLayout", () => ({
+ SETTINGS_PICKER_TRIGGER_CLASSNAME: "",
+ SettingResetButton: ({ label, onClick }: { label: string; onClick: () => void }) => (
+ {`Reset ${label}`}
+ ),
+ SettingsSection: ({ children }: { children: ReactNode }) => children,
+ SettingsRow: ({
+ children,
+ control,
+ resetAction,
+ }: {
+ children: ReactNode;
+ control: ReactNode;
+ resetAction: ReactNode;
+ }) => (
+
+ {control}
+ {resetAction}
+ {children}
+
+ ),
+}));
+vi.mock("../ui/select", () => ({
+ Select: ({ children }: { children: ReactNode }) => children,
+ SelectItem: "span",
+ SelectPopup: "div",
+ SelectTrigger: "div",
+ SelectValue: "span",
+}));
+vi.mock("../ui/switch", () => ({ Switch: "input" }));
+vi.mock("../ui/textarea", () => ({ Textarea: "textarea" }));
+vi.mock("../ui/button", () => ({ Button: "button" }));
+
+import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
+
+let renderer: ReactTestRenderer | null;
+
+function button(label: string) {
+ return renderer!.root.findAllByType("button").find((item) => item.children.includes(label))!;
+}
+
+function openEditor() {
+ act(() => button("Write custom instructions for all").props.onClick());
+}
+
+function editInstructions(value: string) {
+ act(() => renderer!.root.findByType("textarea").props.onChange({ target: { value } }));
+}
+
+function applyInstructions() {
+ act(() => button("Apply instructions to all").props.onClick());
+}
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ state.styles = [
+ {
+ ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle,
+ mode: "custom",
+ customInstructions: "First environment instructions",
+ followChangeRequestTemplates: true,
+ },
+ {
+ ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle,
+ mode: "custom",
+ customInstructions: "Second environment instructions",
+ followChangeRequestTemplates: false,
+ },
+ ];
+ state.updateSettings.mockReset().mockImplementation((patch) => {
+ state.styles = state.styles.map((style) => ({ ...style, ...patch.sourceControlWritingStyle }));
+ });
+ act(() => {
+ renderer = create(
+
+
+ ,
+ );
+ });
+});
+
+afterEach(async () => {
+ await act(async () => renderer?.unmount());
+ renderer = null;
+ vi.unstubAllGlobals();
+});
+
+describe("mixed source control instructions", () => {
+ it("resets mixed template preferences without replacing each environment's instructions", () => {
+ const initialInstructions = state.styles.map(({ mode, customInstructions }) => ({
+ mode,
+ customInstructions,
+ }));
+
+ act(() => button("Reset change request templates").props.onClick());
+
+ expect(state.updateSettings).toHaveBeenCalledTimes(1);
+ expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([
+ DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates,
+ DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates,
+ ]);
+ expect(
+ state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })),
+ ).toEqual(initialInstructions);
+ });
+
+ it("resets every environment even when the representative already has default instructions", () => {
+ state.styles[0] = { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle };
+ act(() => {
+ renderer!.update(
+
+
+ ,
+ );
+ });
+
+ act(() => button("Reset source control writing style").props.onClick());
+
+ expect(state.updateSettings).toHaveBeenCalledTimes(1);
+ expect(
+ state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })),
+ ).toEqual(
+ [0, 1].map(() => ({
+ mode: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.mode,
+ customInstructions: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.customInstructions,
+ })),
+ );
+ expect(state.styles[1]!.followChangeRequestTemplates).toBe(false);
+ });
+
+ it("does not write an untouched bulk draft", () => {
+ const initialStyles = state.styles;
+ openEditor();
+ expect(renderer!.root.findByType("textarea").props.value).toBe("");
+ expect(button("Apply instructions to all").props.disabled).toBe(true);
+
+ applyInstructions();
+ expect(state.updateSettings).not.toHaveBeenCalled();
+ expect(state.styles).toEqual(initialStyles);
+ });
+
+ it("applies edited instructions to every selected environment", () => {
+ openEditor();
+ editInstructions(" Keep titles concise. ");
+ expect(button("Apply instructions to all").props.disabled).toBe(false);
+ applyInstructions();
+
+ expect(state.updateSettings).toHaveBeenCalledTimes(1);
+ expect(state.styles.map((style) => style.customInstructions)).toEqual([
+ "Keep titles concise.",
+ "Keep titles concise.",
+ ]);
+ expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([true, false]);
+ });
+
+ it("allows an intentional clear after editing", () => {
+ openEditor();
+ editInstructions("Temporary instructions");
+ editInstructions("");
+ expect(button("Apply instructions to all").props.disabled).toBe(false);
+ applyInstructions();
+
+ expect(state.updateSettings).toHaveBeenCalledTimes(1);
+ expect(state.styles.map((style) => style.customInstructions)).toEqual(["", ""]);
+ });
+
+ it("shows the plain editor once instructions agree, even while templates differ", () => {
+ openEditor();
+ editInstructions("Shared instructions");
+ applyInstructions();
+
+ expect(state.updateSettings).toHaveBeenCalledTimes(1);
+ expect(state.styles.map((style) => style.customInstructions)).toEqual([
+ "Shared instructions",
+ "Shared instructions",
+ ]);
+ // Template preferences still differ, but that is the templates row's
+ // concern: the instructions editor is no longer a bulk draft.
+ expect(button("Write custom instructions for all")).toBeUndefined();
+ expect(renderer!.root.findByType("textarea").props.defaultValue).toBe("Shared instructions");
+ });
+});
diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx
index 13e330193..83c560f96 100644
--- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx
+++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx
@@ -1,12 +1,21 @@
-import { useAtomValue } from "@effect/atom-react";
import { useNavigate } from "@tanstack/react-router";
-import { useRef } from "react";
-import type { ProviderInstanceId, SourceControlWritingStyleMode } from "@t3tools/contracts";
+import { useRef, useState } from "react";
+import type {
+ ProviderInstanceId,
+ ServerSettings,
+ SourceControlWritingStyleMode,
+} from "@t3tools/contracts";
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import { createModelSelection } from "@t3tools/shared/model";
import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
-import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
+import {
+ useScopedSettings,
+ useScopedSettingsMixed,
+ useUpdateScopedSettings,
+} from "./useScopedSettings";
+import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
+import { useSettingsScope } from "./SettingsScopeContext";
import {
applyProviderInstanceSettings,
deriveProviderInstanceEntries,
@@ -17,13 +26,14 @@ import {
getCustomModelOptionsByInstance,
resolveAppModelSelectionState,
} from "../../modelSelection";
-import { usePrimaryEnvironmentId } from "../../state/environments";
-import { primaryServerProvidersAtom } from "../../state/server";
+import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
import { TraitsPicker } from "../chat/TraitsPicker";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { Switch } from "../ui/switch";
import { Textarea } from "../ui/textarea";
+import { toastManager } from "../ui/toast";
+import { Button } from "../ui/button";
import {
SETTINGS_PICKER_TRIGGER_CLASSNAME,
SettingResetButton,
@@ -51,16 +61,41 @@ const MODE_OPTIONS: Record 0;
+ const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
+ // The writing style is one object; each control only cares about its own field.
+ const styleFieldMixed = (field: keyof ServerSettings["sourceControlWritingStyle"]) => {
+ const first = targets[0];
+ return (
+ first !== undefined &&
+ targets.some(
+ (candidate) =>
+ candidate.settings.sourceControlWritingStyle[field] !==
+ first.settings.sourceControlWritingStyle[field],
+ )
+ );
+ };
+ const modeMixed = styleFieldMixed("mode");
+ const instructionsMixed = styleFieldMixed("customInstructions");
+ const templatesMixed = styleFieldMixed("followChangeRequestTemplates");
+ const writingStyleMixed = modeMixed || instructionsMixed;
+ const mixedWriterModel = useScopedSettingsMixed(["sourceControlWriterModelSelection"]);
const customInstructionsRef = useRef(null);
+ const [editingAllInstructions, setEditingAllInstructions] = useState(false);
+ const [allInstructions, setAllInstructions] = useState(null);
const style = settings.sourceControlWritingStyle;
const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
const isSourceControlWritingStyleDirty =
- style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions;
+ writingStyleMixed ||
+ style.mode !== defaults.mode ||
+ style.customInstructions !== defaults.customInstructions;
const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders);
const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;
@@ -85,6 +120,9 @@ export function SourceControlWritingSettingsSection() {
settings,
),
);
+ const canEnableDedicatedModel = instanceEntries.some(
+ (entry) => entry.enabled && entry.isAvailable,
+ );
const modelOptionsByInstance = getCustomModelOptionsByInstance(
settings,
backgroundTextGenerationProviders,
@@ -106,11 +144,14 @@ export function SourceControlWritingSettingsSection() {
},
serverProviders,
);
+ const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries);
return (
{
const customInstructions = customInstructionsRef.current?.value.trim();
updateSettings({
@@ -146,7 +187,11 @@ export function SourceControlWritingSettingsSection() {
className="w-full sm:w-56"
aria-label="Source control writing style"
>
- {MODE_OPTIONS[style.mode].label}
+
+ {(value: SourceControlWritingStyleMode | null) =>
+ value === null ? "Mixed" : MODE_OPTIONS[value].label
+ }
+
{(Object.keys(MODE_OPTIONS) as SourceControlWritingStyleMode[]).map((mode) => (
@@ -158,7 +203,49 @@ export function SourceControlWritingSettingsSection() {
}
>
- {style.mode === "custom" ? (
+ {writingStyleMixed ? (
+
+ {editingAllInstructions ? (
+ <>
+ setAllInstructions(event.target.value)}
+ rows={4}
+ aria-label="Custom source control instructions for all selected environments"
+ placeholder="Write the instructions each selected environment should use."
+ />
+ {
+ if (allInstructions === null) return;
+ updateSettings({
+ sourceControlWritingStyle: {
+ mode: "custom",
+ customInstructions: allInstructions.trim(),
+ },
+ });
+ setEditingAllInstructions(false);
+ }}
+ >
+ Apply instructions to all
+
+ >
+ ) : (
+ {
+ setAllInstructions(null);
+ setEditingAllInstructions(true);
+ }}
+ >
+ Write custom instructions for all
+
+ )}
+
+ ) : style.mode === "custom" ? (
updateSettings({
sourceControlWritingStyle: {
@@ -213,82 +304,106 @@ export function SourceControlWritingSettingsSection() {
- {usesDedicatedModel ? (
- <>
- {
- void navigate({
- to: "/settings/providers",
- search: { environmentId, instanceId },
- });
- },
- }
- : {})}
- onInstanceModelChange={(instanceId, model) => {
- updateSettings({
- sourceControlWriterModelSelection: normalizeDedicatedSelection(
- instanceId,
- model,
- ),
- });
- }}
- />
- {activeInstanceEntry ? (
-
+ Connect an environment to choose its source control writer model.
+
+ ) : (
+
+ {usesDedicatedModel && !canEnableDedicatedModel ? (
+
+ No text generation providers available.
+
+ ) : null}
+ {usesDedicatedModel && canEnableDedicatedModel ? (
+ <>
+
{}}
- modelOptions={activeSelection.options}
- allowPromptInjectedEffort={false}
- planModeEnabled={settings.planModeEnabled}
- capabilityContext="background-text-generation"
+ lockedProvider={null}
+ instanceEntries={instanceEntries}
+ modelOptionsByInstance={modelOptionsByInstance}
triggerVariant="outline"
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
- onModelOptionsChange={(nextOptions) => {
+ triggerAriaLabel="Source control writer model"
+ {...(mixedWriterModel ? { triggerLabel: "Mixed" } : {})}
+ {...(environmentId
+ ? {
+ onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
+ void navigate({
+ to: "/settings/providers",
+ search: { environmentId, instanceId },
+ });
+ },
+ }
+ : {})}
+ getModelDisabledReason={writerModelDisabledReason}
+ onInstanceModelChange={(instanceId, model) => {
+ const reason = writerModelDisabledReason(instanceId, model);
+ if (reason) {
+ toastManager.add({
+ type: "error",
+ title: "Source control writer model not saved",
+ description: reason,
+ });
+ return;
+ }
updateSettings({
sourceControlWriterModelSelection: normalizeDedicatedSelection(
- activeSelection.instanceId,
- activeSelection.model,
- nextOptions,
+ instanceId,
+ model,
),
});
}}
/>
- ) : null}
- >
- ) : null}
-
- updateSettings({
- sourceControlWriterModelSelection: checked
- ? createModelSelection(
- defaultModelSelection.instanceId,
- defaultModelSelection.model,
- defaultModelSelection.options,
- )
- : null,
- })
- }
- aria-label="Use a separate source control writer model"
- />
-
+ {activeInstanceEntry ? (
+ {}}
+ modelOptions={activeSelection.options}
+ allowPromptInjectedEffort={false}
+ planModeEnabled={settings.planModeEnabled}
+ capabilityContext="background-text-generation"
+ triggerVariant="outline"
+ triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
+ onModelOptionsChange={(nextOptions) => {
+ updateSettings({
+ sourceControlWriterModelSelection: normalizeDedicatedSelection(
+ activeSelection.instanceId,
+ activeSelection.model,
+ nextOptions,
+ ),
+ });
+ }}
+ />
+ ) : null}
+ >
+ ) : null}
+
+ updateSettings({
+ sourceControlWriterModelSelection: checked
+ ? createModelSelection(
+ defaultModelSelection.instanceId,
+ defaultModelSelection.model,
+ defaultModelSelection.options,
+ )
+ : null,
+ })
+ }
+ aria-label="Use a separate source control writer model"
+ />
+
+ )
}
/>
diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts
new file mode 100644
index 000000000..87173660e
--- /dev/null
+++ b/apps/web/src/components/settings/scopedSettings.test.ts
@@ -0,0 +1,577 @@
+import {
+ DEFAULT_SERVER_SETTINGS,
+ EnvironmentId,
+ ProjectId,
+ ProviderInstanceId,
+ type ServerSettings,
+} from "@t3tools/contracts";
+import { describe, expect, it, vi } from "vite-plus/test";
+import { createModelSelection } from "@t3tools/shared/model";
+
+import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
+import {
+ listProjectOverrides,
+ persistScopedSettingsPatch,
+ planProjectOverridesClear,
+ planScopedSettingsClear,
+ planScopedSettingsPatch,
+ resolveScopedSettingsTargets,
+ scopedSettingsAreMixed,
+ scopedSettingsSource,
+ selectScopedSettingsEnvironments,
+} from "./scopedSettings";
+import { resolveSettingsScope } from "./settingsScope";
+
+function environment(
+ id: string,
+ options: {
+ connected?: boolean;
+ loaded?: boolean;
+ settings?: Partial;
+ projectOverrides?: boolean;
+ capabilities?: {
+ projectDefaults?: boolean;
+ threadRestartContinuation?: boolean;
+ threadAutoSettlement?: boolean;
+ defaultRuntimeMode?: boolean;
+ };
+ } = {},
+) {
+ return {
+ environmentId: EnvironmentId.make(id),
+ label: id,
+ connection: {
+ phase: options.connected === false ? ("offline" as const) : ("connected" as const),
+ },
+ serverConfig:
+ options.loaded === false
+ ? null
+ : {
+ settings: { ...DEFAULT_SERVER_SETTINGS, ...options.settings },
+ environment: {
+ capabilities: {
+ projectSettingsOverrides: options.projectOverrides !== false,
+ projectDefaults: true,
+ threadRestartContinuation: true,
+ threadAutoSettlement: true,
+ defaultRuntimeMode: true,
+ ...options.capabilities,
+ },
+ },
+ },
+ };
+}
+
+const laptop = environment("Laptop");
+const server = environment("Server");
+const offline = environment("Offline", { connected: false });
+const loading = environment("Loading", { loaded: false });
+const environments = [laptop, server, offline, loading];
+const all = resolveSettingsScope({}, [], environments);
+const named = resolveSettingsScope({ machine: server.environmentId }, [], environments);
+
+const projectId = ProjectId.make("project");
+const laptopProjectId = ProjectId.make("laptop-project");
+const member = {
+ id: projectId,
+ environmentId: server.environmentId,
+ title: "Project",
+ workspaceRoot: "/repo",
+ physicalProjectKey: `${server.environmentId}:/repo`,
+ environmentLabel: server.label,
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: "2026-09-07T00:00:00.000Z",
+ updatedAt: "2026-09-07T00:00:00.000Z",
+};
+const laptopMember = {
+ ...member,
+ id: laptopProjectId,
+ environmentId: laptop.environmentId,
+ physicalProjectKey: `${laptop.environmentId}:/repo`,
+ environmentLabel: laptop.label,
+};
+const group: SidebarProjectSnapshot = {
+ ...member,
+ projectKey: "project-group",
+ displayName: "Project",
+ memberProjects: [member, laptopMember],
+ memberProjectRefs: [
+ { environmentId: server.environmentId, projectId },
+ { environmentId: laptop.environmentId, projectId: laptopProjectId },
+ ],
+ groupedProjectCount: 2,
+ environmentPresence: "remote-only",
+ allRemoteMembersAreDesktopLocal: false,
+ allRemoteMembersAreWsl: false,
+ remoteEnvironmentLabels: [server.label, laptop.label],
+};
+const project = resolveSettingsScope({ project: group.projectKey }, [group], environments);
+const checkout = resolveSettingsScope(
+ { project: group.projectKey, machine: server.environmentId, checkout: member.physicalProjectKey },
+ [group],
+ environments,
+);
+
+describe("scoped settings targets", () => {
+ it("uses the named environment even when a different primary is available", () => {
+ const selected = selectScopedSettingsEnvironments(named, environments, laptop.environmentId);
+ expect(selected.environments).toEqual([server]);
+ expect(selected.environment).toBe(server);
+ });
+
+ it("keeps an offline named environment selected without falling back to primary", () => {
+ const scope = resolveSettingsScope({ machine: offline.environmentId }, [], environments);
+ const selected = selectScopedSettingsEnvironments(scope, environments, laptop.environmentId);
+ expect(selected.environments).toEqual([offline]);
+ expect(selected.connectedEnvironments).toEqual([]);
+ expect(selected.environment).toBeNull();
+ expect(
+ planScopedSettingsPatch(scope, environments, { enableProviderUpdateChecks: false }),
+ ).toMatchObject({
+ serverWrites: [],
+ unavailableReason: "Connect Offline to save this setting.",
+ });
+ });
+
+ it("prefers the selected primary as the aggregate representative without including disconnected targets", () => {
+ const selected = selectScopedSettingsEnvironments(all, environments, server.environmentId);
+ expect(selected.environment).toBe(server);
+ expect(selected.environments).toEqual(environments);
+ expect(selected.connectedEnvironments).toEqual([laptop, server]);
+ });
+
+ it("resolves each member's effective settings and source at project scope", () => {
+ const overridden = environment("Server", {
+ settings: {
+ defaultAutoPull: false,
+ projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } },
+ },
+ });
+ const targets = resolveScopedSettingsTargets(project, [laptop, overridden]);
+ expect(targets.map((target) => [target.projectId, target.settings.defaultAutoPull])).toEqual([
+ [projectId, true],
+ [laptopProjectId, false],
+ ]);
+ expect(scopedSettingsSource(targets, ["defaultAutoPull"])).toBe("mixed");
+ expect(scopedSettingsSource([targets[0]!], ["defaultAutoPull"])).toBe("project");
+ expect(scopedSettingsSource(targets, ["enableProviderUpdateChecks"])).toBe("environment");
+ expect(scopedSettingsAreMixed(targets, ["defaultAutoPull"])).toBe(true);
+ });
+
+ it("keeps a disabled provider override visible and individually resettable", () => {
+ const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
+ const configured = environment("Server", {
+ settings: {
+ providers: {
+ ...DEFAULT_SERVER_SETTINGS.providers,
+ claudeAgent: {
+ ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent,
+ enabled: false,
+ },
+ },
+ projectSettingsOverrides: {
+ [projectId]: {
+ defaultModelSelection: selection,
+ defaultAutoPull: true,
+ },
+ },
+ },
+ });
+ const [target] = resolveScopedSettingsTargets(project, [configured]);
+ expect(target?.settings.defaultModelSelection).toEqual(
+ DEFAULT_SERVER_SETTINGS.defaultModelSelection,
+ );
+ expect(scopedSettingsSource(target ? [target] : [], ["defaultModelSelection"])).toBe("project");
+ const reset = planScopedSettingsClear(project, [configured], ["defaultModelSelection"]);
+ expect(reset.serverWrites).toHaveLength(1);
+ expect(reset.serverWrites[0]?.patch.projectSettingsOverrides).toEqual({
+ [projectId]: { defaultAutoPull: true },
+ });
+ });
+});
+
+describe("scoped settings writes", () => {
+ it("stores project device permission without changing the environment's permission", () => {
+ const plan = planScopedSettingsPatch(checkout, [server], { enableAgentDeviceAccess: true });
+ expect(plan.serverWrites[0]?.patch).toEqual({
+ projectSettingsOverrides: {
+ [projectId]: { enableAgentDeviceAccess: true },
+ },
+ });
+ expect(server.serverConfig?.settings.enableAgentDeviceAccess).toBe(false);
+ });
+ it.each([
+ ["projectDefaults", { defaultModelSelection: null }],
+ ["threadRestartContinuation", { continueThreadsAfterServerUpdate: true }],
+ ["threadAutoSettlement", { sidebarAutoSettleOnMerge: false }],
+ ["defaultRuntimeMode", { defaultRuntimeMode: "approval-required" }],
+ ] as const)("does not silently acknowledge unsupported %s writes", async (capability, patch) => {
+ const legacy = environment("Server", {
+ projectOverrides: false,
+ capabilities: { [capability]: false },
+ });
+ const available = [laptop, legacy];
+ const scope = resolveSettingsScope({}, [], available);
+ const plan = planScopedSettingsPatch(scope, available, patch);
+ expect(plan.serverWrites.map((write) => write.environmentId)).toEqual([laptop.environmentId]);
+ expect(plan.skippedEnvironments).toEqual([
+ { environmentId: legacy.environmentId, label: legacy.label },
+ ]);
+ const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
+ const result = await persistScopedSettingsPatch(plan, persistServer, vi.fn());
+ expect(result.savedEnvironmentCount).toBe(1);
+ expect(result.failedEnvironments).toEqual(plan.skippedEnvironments);
+ expect(persistServer).toHaveBeenCalledTimes(1);
+ });
+
+ it("gates generic overrides independently of legacy project-default support", () => {
+ const legacy = environment("Server", { projectOverrides: false });
+ const plan = planScopedSettingsPatch(named, [legacy], {
+ projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } },
+ });
+ expect(plan.serverWrites).toEqual([]);
+ expect(plan.unavailableReason).toContain("Update");
+ // The environment merge-method default was introduced with generic overrides.
+ const mergeMethod = planScopedSettingsPatch(named, [legacy], {
+ pullRequestMergeMethod: "squash",
+ });
+ expect(mergeMethod.serverWrites).toEqual([]);
+ expect(mergeMethod.unavailableReason).toContain("Update");
+ });
+
+ it("requires the permissions-default capability even on an environment with generic overrides", () => {
+ const older = environment("Server", { capabilities: { defaultRuntimeMode: false } });
+ const plan = planScopedSettingsPatch(checkout, [older], {
+ defaultRuntimeMode: "approval-required",
+ });
+ expect(plan.serverWrites).toEqual([]);
+ expect(plan.skippedEnvironments).toEqual([
+ { environmentId: older.environmentId, label: older.label },
+ ]);
+ });
+
+ it("reports disconnected targets while preserving local-only preferences", async () => {
+ const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
+ const persistClient = vi.fn();
+ const plan = planScopedSettingsPatch(all, environments, {
+ enableProviderUpdateChecks: false,
+ diffIgnoreWhitespace: false,
+ });
+ const result = await persistScopedSettingsPatch(plan, persistServer, persistClient);
+ expect(result.savedEnvironmentCount).toBe(2);
+ expect(result.failedEnvironments.map((entry) => entry.environmentId)).toEqual([
+ offline.environmentId,
+ loading.environmentId,
+ ]);
+ expect(persistClient).toHaveBeenCalledExactlyOnceWith({ diffIgnoreWhitespace: false });
+ expect(
+ planScopedSettingsPatch(all, environments, { diffIgnoreWhitespace: false })
+ .skippedEnvironments,
+ ).toEqual([]);
+ });
+
+ it("isolates a formerly shared server preference to the named environment", async () => {
+ const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
+ const persistClient = vi.fn();
+ await persistScopedSettingsPatch(
+ planScopedSettingsPatch(named, environments, { sidebarAutoSettleOnMerge: false }),
+ persistServer,
+ persistClient,
+ );
+ expect(persistServer.mock.calls).toEqual([
+ [
+ {
+ environmentId: server.environmentId,
+ input: { patch: { sidebarAutoSettleOnMerge: false } },
+ },
+ ],
+ ]);
+ expect(persistClient).not.toHaveBeenCalled();
+ });
+
+ it("writes an aggregate preference only to connected environments with loaded configuration", async () => {
+ const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
+ const persistClient = vi.fn();
+ await persistScopedSettingsPatch(
+ planScopedSettingsPatch(all, environments, { enableProviderUpdateChecks: false }),
+ persistServer,
+ persistClient,
+ );
+ expect(persistServer.mock.calls.map(([input]) => input.environmentId)).toEqual([
+ laptop.environmentId,
+ server.environmentId,
+ ]);
+ expect(persistClient).not.toHaveBeenCalled();
+ });
+
+ it("persists client keys locally at any scope alongside server keys", async () => {
+ const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
+ const persistClient = vi.fn();
+ await persistScopedSettingsPatch(
+ planScopedSettingsPatch(named, environments, {
+ diffIgnoreWhitespace: false,
+ enableProviderUpdateChecks: false,
+ }),
+ persistServer,
+ persistClient,
+ );
+ expect(persistClient).toHaveBeenCalledExactlyOnceWith({ diffIgnoreWhitespace: false });
+ expect(persistServer).toHaveBeenCalledExactlyOnceWith({
+ environmentId: server.environmentId,
+ input: { patch: { enableProviderUpdateChecks: false } },
+ });
+ });
+
+ it("writes project overrides into each member's entry on its environment", () => {
+ const withExisting = environment("Server", {
+ settings: {
+ projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } },
+ },
+ });
+ const plan = planScopedSettingsPatch(project, [laptop, withExisting], {
+ defaultAutoPull: true,
+ });
+ expect(plan.unavailableReason).toBeNull();
+ expect(plan.serverWrites).toEqual([
+ {
+ environmentId: server.environmentId,
+ label: server.label,
+ patch: {
+ projectSettingsOverrides: {
+ [projectId]: { enableAgentBrowserAccess: false, defaultAutoPull: true },
+ },
+ },
+ },
+ {
+ environmentId: laptop.environmentId,
+ label: laptop.label,
+ patch: { projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } } },
+ },
+ ]);
+ expect(
+ planScopedSettingsPatch(checkout, [laptop, server], { defaultAutoPull: true }),
+ ).toMatchObject({
+ serverWrites: [{ environmentId: server.environmentId }],
+ });
+ });
+
+ it("refuses environment-wide keys and older servers at project scope", () => {
+ expect(
+ planScopedSettingsPatch(project, environments, { enableProviderUpdateChecks: false }),
+ ).toMatchObject({
+ serverWrites: [],
+ unavailableReason: "This setting is environment-wide and cannot be overridden by a project.",
+ });
+ const legacy = environment("Server", { projectOverrides: false });
+ expect(
+ planScopedSettingsPatch(checkout, [laptop, legacy], { defaultAutoPull: true }),
+ ).toMatchObject({ serverWrites: [], unavailableReason: expect.stringContaining("update") });
+ });
+
+ it("clears overrides per member and removes an emptied entry", () => {
+ const withOverrides = environment("Server", {
+ settings: {
+ projectSettingsOverrides: {
+ [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
+ },
+ },
+ });
+ const plan = planScopedSettingsClear(checkout, [laptop, withOverrides], ["defaultAutoPull"]);
+ expect(plan.serverWrites).toEqual([
+ {
+ environmentId: server.environmentId,
+ label: server.label,
+ patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } } },
+ },
+ ]);
+ expect(
+ planScopedSettingsClear(
+ checkout,
+ [laptop, withOverrides],
+ ["defaultAutoPull", "enableAgentBrowserAccess"],
+ ).serverWrites[0]?.patch,
+ ).toEqual({ projectSettingsOverrides: { [projectId]: null } });
+ });
+
+ it("never substitutes an environment-default write for an invalid scope", () => {
+ const scope = resolveSettingsScope({ machine: "removed" }, [group], environments);
+ const plan = planScopedSettingsPatch(scope, environments, { enableAgentBrowserAccess: false });
+ expect(plan.serverWrites).toEqual([]);
+ expect(plan.hasClientWrite).toBe(false);
+ expect(plan.unavailableReason).not.toBeNull();
+ });
+
+ it("waits for every target and identifies both RPC failures and rejected writes", async () => {
+ const third = environment("Third");
+ const fourth = environment("Fourth");
+ const selected = [...environments, third, fourth];
+ const scope = resolveSettingsScope({}, [], selected);
+ const persistServer = vi
+ .fn()
+ .mockResolvedValueOnce({ _tag: "Success" })
+ .mockResolvedValueOnce({ _tag: "Failure" })
+ .mockRejectedValueOnce(new Error("Disconnected during save"))
+ .mockResolvedValueOnce({ _tag: "Success" });
+ const result = await persistScopedSettingsPatch(
+ planScopedSettingsPatch(scope, selected, { enableAgentBrowserAccess: false }),
+ persistServer,
+ vi.fn(),
+ );
+ expect(result.savedEnvironmentCount).toBe(2);
+ expect(result.failedEnvironments.map(({ label }) => label)).toEqual([
+ server.label,
+ third.label,
+ offline.label,
+ loading.label,
+ ]);
+ expect(persistServer).toHaveBeenCalledTimes(4);
+ });
+});
+
+describe("scoped settings mixed values", () => {
+ it("compares only requested settings across connected targets", () => {
+ const changed = environment("Changed", { settings: { enableAgentBrowserAccess: false } });
+ const targets = resolveScopedSettingsTargets(all, [laptop, changed]);
+ expect(scopedSettingsAreMixed(targets, ["enableAgentBrowserAccess"])).toBe(true);
+ expect(scopedSettingsAreMixed(targets, ["enableProviderUpdateChecks"])).toBe(false);
+ expect(scopedSettingsAreMixed([], ["enableAgentBrowserAccess"])).toBe(false);
+ });
+
+ it("treats independently decoded equal nested settings as the same value", () => {
+ const style = {
+ ...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle,
+ mode: "custom" as const,
+ customInstructions: "Use plain language",
+ };
+ const first = environment("First", { settings: { sourceControlWritingStyle: { ...style } } });
+ const second = environment("Second", { settings: { sourceControlWritingStyle: { ...style } } });
+ const targets = resolveScopedSettingsTargets(all, [first, second]);
+ expect(scopedSettingsAreMixed(targets, ["sourceControlWritingStyle"])).toBe(false);
+ });
+});
+
+describe("project overrides at environment scope", () => {
+ const laptop = EnvironmentId.make("laptop");
+ const desk = EnvironmentId.make("desk");
+ const fleet = ProjectId.make("fleet");
+ const t3 = ProjectId.make("t3");
+ const environment = (
+ environmentId: EnvironmentId,
+ overrides: ServerSettings["projectSettingsOverrides"],
+ ) => ({
+ environmentId,
+ label: environmentId,
+ connection: { phase: "connected" as const },
+ serverConfig: {
+ settings: { ...DEFAULT_SERVER_SETTINGS, projectSettingsOverrides: overrides },
+ environment: { capabilities: { projectSettingsOverrides: true } },
+ },
+ });
+
+ it("lists only the projects that override the keys", () => {
+ const entries = listProjectOverrides(
+ [
+ environment(laptop, {
+ [fleet]: { defaultAutoPull: true, defaultThreadEnvMode: "local" },
+ [t3]: { defaultThreadEnvMode: "local" },
+ }),
+ environment(desk, { [fleet]: { defaultAutoPull: false } }),
+ ],
+ ["defaultAutoPull"],
+ );
+ expect(entries).toEqual([
+ { environmentId: laptop, projectId: fleet },
+ { environmentId: desk, projectId: fleet },
+ ]);
+ });
+
+ it("clears only those keys and drops entries that become empty", () => {
+ const plan = planProjectOverridesClear(
+ [
+ environment(laptop, {
+ [fleet]: { defaultAutoPull: true, defaultThreadEnvMode: "local" },
+ [t3]: { defaultAutoPull: true },
+ }),
+ ],
+ [
+ { environmentId: laptop, projectId: fleet },
+ { environmentId: laptop, projectId: t3 },
+ ],
+ ["defaultAutoPull"],
+ );
+ expect(plan.serverWrites).toEqual([
+ {
+ environmentId: laptop,
+ label: laptop,
+ patch: {
+ projectSettingsOverrides: { [fleet]: { defaultThreadEnvMode: "local" }, [t3]: 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");
+ const projectId = ProjectId.make("fleet");
+ const member = {
+ id: projectId,
+ environmentId,
+ physicalProjectKey: "laptop:/repo",
+ environmentLabel: "Laptop",
+ title: "fleet",
+ workspaceRoot: "/repo",
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ };
+ const settings = {
+ ...DEFAULT_SERVER_SETTINGS,
+ projectSettingsOverrides: {
+ [projectId]: {
+ sourceControlWritingStyle: {
+ mode: "custom" as const,
+ customInstructions: "Keep it short.",
+ followChangeRequestTemplates: false,
+ },
+ },
+ },
+ };
+ const plan = planScopedSettingsPatch(
+ {
+ kind: "project",
+ group: {} as never,
+ environmentId: null,
+ label: "fleet",
+ members: [member as never],
+ environmentIds: [environmentId],
+ },
+ [
+ {
+ environmentId,
+ label: "Laptop",
+ connection: { phase: "connected" },
+ serverConfig: {
+ settings,
+ environment: { capabilities: { projectSettingsOverrides: true } },
+ },
+ },
+ ],
+ { sourceControlWritingStyle: { customInstructions: "Be terse." } },
+ );
+ expect(plan.serverWrites[0]?.patch).toEqual({
+ projectSettingsOverrides: {
+ [projectId]: {
+ sourceControlWritingStyle: {
+ mode: "custom",
+ customInstructions: "Be terse.",
+ followChangeRequestTemplates: false,
+ },
+ },
+ },
+ });
+ });
+});
diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts
new file mode 100644
index 000000000..80d62a8d0
--- /dev/null
+++ b/apps/web/src/components/settings/scopedSettings.ts
@@ -0,0 +1,452 @@
+import {
+ ClientSettingsSchema,
+ type ClientSettingsPatch,
+ type EnvironmentId,
+ PROJECT_SCOPED_SERVER_SETTING_KEYS,
+ type ProjectId,
+ type ProjectScopedServerSettingKey,
+ type ProjectSettingsOverrides,
+ ServerSettings,
+ type ServerSettingsPatch,
+} from "@t3tools/contracts";
+import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+import {
+ clearProjectSettingsOverrides,
+ resolveProjectSettings,
+ type ProjectSettingSource,
+} from "@t3tools/shared/projectSettings";
+import * as Equal from "effect/Equal";
+
+import type { ResolvedSettingsScope } from "./settingsScope";
+import { settingRequiresProjectDefaults } from "./ProjectSettingsPanel.logic";
+
+export type ScopedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
+
+interface ScopedSettingsEnvironment {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly connection: { readonly phase: EnvironmentConnectionPhase };
+ readonly serverConfig: {
+ readonly settings: ServerSettings;
+ readonly environment?: {
+ readonly capabilities: {
+ readonly projectSettingsOverrides?: boolean | undefined;
+ readonly projectDefaults?: boolean | undefined;
+ readonly threadRestartContinuation?: boolean | undefined;
+ readonly threadAutoSettlement?: boolean | undefined;
+ readonly defaultRuntimeMode?: boolean | undefined;
+ };
+ };
+ } | null;
+}
+
+const SERVER_KEYS = new Set(Object.keys(ServerSettings.fields));
+const CLIENT_KEYS = new Set(Object.keys(ClientSettingsSchema.fields));
+const PROJECT_SCOPED_KEYS = new Set(PROJECT_SCOPED_SERVER_SETTING_KEYS);
+
+export function isProjectScopedSettingKey(key: string): key is ProjectScopedServerSettingKey {
+ return PROJECT_SCOPED_KEYS.has(key);
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** The representative supplies display values, never the set of write targets. */
+export function selectScopedSettingsEnvironments(
+ scope: ResolvedSettingsScope,
+ available: readonly T[],
+ primaryEnvironmentId: EnvironmentId | null,
+) {
+ const selectedIds = new Set(scope.environmentIds);
+ const environments = available.filter((environment) =>
+ selectedIds.has(environment.environmentId),
+ );
+ const connectedEnvironments = environments.filter(
+ (environment) =>
+ environment.connection.phase === "connected" && environment.serverConfig !== null,
+ );
+ const environment =
+ connectedEnvironments.find((candidate) => candidate.environmentId === primaryEnvironmentId) ??
+ connectedEnvironments[0] ??
+ null;
+ return { environments, connectedEnvironments, environment };
+}
+
+/**
+ * One (environment, project) pair the scope writes to, with that project's
+ * effective settings. Environment scopes have no member and read the
+ * environment settings directly.
+ */
+export interface ScopedSettingsTarget {
+ readonly environmentId: EnvironmentId;
+ /** The environment's label; a project is the same project on every environment. */
+ readonly label: string;
+ readonly projectId: ProjectId | null;
+ readonly settings: ServerSettings;
+ readonly sources: Readonly>;
+}
+
+/** Effective settings per connected target: members at project scope, environments otherwise. */
+export function resolveScopedSettingsTargets(
+ scope: ResolvedSettingsScope,
+ connectedEnvironments: readonly ScopedSettingsEnvironment[],
+): readonly ScopedSettingsTarget[] {
+ const byId = new Map(
+ connectedEnvironments.map((environment) => [environment.environmentId, environment]),
+ );
+ if (scope.kind === "project" || scope.kind === "checkout") {
+ return scope.members.flatMap((member) => {
+ const environment = byId.get(member.environmentId);
+ if (!environment?.serverConfig) return [];
+ const resolved = resolveProjectSettings(environment.serverConfig.settings, member.id, member);
+ // Settings editing must expose a stored override even when runtime
+ // resolution falls back because its provider is disabled. Otherwise
+ // admission still sees the preference but the per-key reset disappears.
+ const sources = { ...resolved.sources };
+ for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
+ if (Object.hasOwn(resolved.overrides, key)) sources[key] = "project";
+ }
+ return [
+ {
+ environmentId: member.environmentId,
+ label: environment.label,
+ projectId: member.id,
+ settings: resolved.settings,
+ sources,
+ },
+ ];
+ });
+ }
+ return connectedEnvironments.flatMap((environment) =>
+ environment.serverConfig
+ ? [
+ {
+ environmentId: environment.environmentId,
+ label: environment.label,
+ projectId: null,
+ settings: environment.serverConfig.settings,
+ sources: resolveProjectSettings(environment.serverConfig.settings, null).sources,
+ },
+ ]
+ : [],
+ );
+}
+
+export function scopedSettingsAreMixed(
+ targets: readonly Pick[],
+ keys: readonly (keyof ServerSettings)[],
+): boolean {
+ const first = targets[0];
+ return (
+ first !== undefined &&
+ targets.some((candidate) =>
+ keys.some((key) => !Equal.equals(first.settings[key], candidate.settings[key])),
+ )
+ );
+}
+
+export type ScopedSettingSource = ProjectSettingSource | "mixed";
+
+/** Whether the keys are overridden on every target, inherited on every target, or split. */
+export function scopedSettingsSource(
+ targets: readonly Pick[],
+ keys: readonly (keyof ServerSettings)[],
+): ScopedSettingSource {
+ const scoped = keys.filter(isProjectScopedSettingKey);
+ if (scoped.length === 0 || targets.length === 0) return "environment";
+ const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key])));
+ return sources.size > 1 ? "mixed" : sources.has("project") ? "project" : "environment";
+}
+
+interface ScopedServerWrite {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly patch: ServerSettingsPatch;
+}
+
+function projectOverrideWrites(
+ scope: Extract,
+ environments: readonly ScopedSettingsEnvironment[],
+ update: (
+ current: ProjectSettingsOverrides,
+ settings: ServerSettings,
+ projectId: ProjectId,
+ ) => ProjectSettingsOverrides | null,
+): ScopedServerWrite[] {
+ const byId = new Map(environments.map((environment) => [environment.environmentId, environment]));
+ const writes = new Map();
+ for (const member of scope.members) {
+ const environment = byId.get(member.environmentId);
+ if (
+ !environment?.serverConfig ||
+ environment.connection.phase !== "connected" ||
+ environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true
+ ) {
+ continue;
+ }
+ const settings = environment.serverConfig.settings;
+ const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id);
+ const existing = writes.get(member.environmentId);
+ writes.set(member.environmentId, {
+ environmentId: member.environmentId,
+ label: environment.label,
+ patch: {
+ projectSettingsOverrides: {
+ ...existing?.patch.projectSettingsOverrides,
+ [member.id]: entry,
+ },
+ },
+ });
+ }
+ return [...writes.values()];
+}
+
+interface SkippedSettingsEnvironment {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+}
+
+function supportsScopedPatch(
+ environment: ScopedSettingsEnvironment,
+ keys: readonly string[],
+ projectScope: boolean,
+): boolean {
+ const capabilities = environment.serverConfig?.environment?.capabilities;
+ if (projectScope && capabilities?.projectSettingsOverrides !== true) return false;
+ return keys.every((key) => {
+ if (key === "defaultRuntimeMode") return capabilities?.defaultRuntimeMode === true;
+ if (key === "projectSettingsOverrides" || key === "pullRequestMergeMethod") {
+ return capabilities?.projectSettingsOverrides === true;
+ }
+ // Generic overrides imply support for the original project-scoped keys.
+ if (capabilities?.projectSettingsOverrides === true) return true;
+ if (settingRequiresProjectDefaults(key as keyof ServerSettingsPatch)) {
+ return capabilities?.projectDefaults === true;
+ }
+ if (key === "continueThreadsAfterServerUpdate") {
+ return capabilities?.threadRestartContinuation === true;
+ }
+ if (key === "sidebarAutoSettleOnMerge" || key === "sidebarAutoSettleAfterDays") {
+ return capabilities?.threadAutoSettlement === true;
+ }
+ return true;
+ });
+}
+
+function skippedSettingsEnvironments(
+ ids: readonly EnvironmentId[],
+ environments: readonly ScopedSettingsEnvironment[],
+ writes: readonly ScopedServerWrite[],
+): SkippedSettingsEnvironment[] {
+ const saved = new Set(writes.map((write) => write.environmentId));
+ const byId = new Map(environments.map((environment) => [environment.environmentId, environment]));
+ return [...new Set(ids)]
+ .filter((id) => !saved.has(id))
+ .map((environmentId) => ({
+ environmentId,
+ label: byId.get(environmentId)?.label ?? environmentId,
+ }));
+}
+
+/**
+ * Environment scopes write the patch to every connected environment; project
+ * and checkout scopes write the scopable keys into each member's override
+ * entry on its environment. Client keys always persist locally.
+ */
+export function planScopedSettingsPatch(
+ scope: ResolvedSettingsScope,
+ environments: readonly ScopedSettingsEnvironment[],
+ patch: ScopedSettingsPatch,
+) {
+ const clientPatch = Object.fromEntries(
+ Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key)),
+ ) as ClientSettingsPatch;
+ const serverPatch = Object.fromEntries(
+ Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)),
+ ) as ServerSettingsPatch;
+ const serverKeys = Object.keys(serverPatch);
+ const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null);
+ const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
+ const unscopableKeys = isProjectScope
+ ? serverKeys.filter((key) => !isProjectScopedSettingKey(key))
+ : [];
+ const serverWrites: ScopedServerWrite[] =
+ serverKeys.length === 0
+ ? []
+ : isProjectScope
+ ? unscopableKeys.length > 0
+ ? []
+ : projectOverrideWrites(
+ scope,
+ environments.filter((environment) =>
+ supportsScopedPatch(environment, serverKeys, true),
+ ),
+ (current, settings, projectId) => {
+ // Object-valued keys arrive as partial patches (the writing style
+ // rows send one field); an override entry stores the whole value,
+ // so complete the patch from the target's effective value.
+ const effective = resolveProjectSettings(settings, projectId).settings;
+ const next: Record = { ...current };
+ for (const [key, value] of Object.entries(serverPatch)) {
+ const base = effective[key as keyof ServerSettings];
+ next[key] =
+ isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
+ }
+ return next as ProjectSettingsOverrides;
+ },
+ )
+ : scope.kind === "all" || scope.kind === "environment"
+ ? connectedEnvironments
+ .filter((environment) => supportsScopedPatch(environment, serverKeys, false))
+ .map((environment) => ({
+ environmentId: environment.environmentId,
+ label: environment.label,
+ patch: serverPatch,
+ }))
+ : [];
+ const hasClientWrite = Object.keys(clientPatch).length > 0;
+ const hasWrite = hasClientWrite || serverWrites.length > 0;
+ const unavailableReason =
+ hasWrite || Object.keys(patch).length === 0
+ ? null
+ : scope.kind === "unavailable"
+ ? scope.message
+ : unscopableKeys.length > 0
+ ? "This setting is environment-wide and cannot be overridden by a project."
+ : isProjectScope
+ ? "Connect the selected checkouts, or update their environments, to save a project override."
+ : connectedEnvironments.length > 0
+ ? "Update the selected environments to save this setting."
+ : `Connect ${scope.kind === "environment" ? scope.label : "an environment"} to save this setting.`;
+ const skippedEnvironments =
+ serverKeys.length === 0
+ ? []
+ : skippedSettingsEnvironments(scope.environmentIds, environments, serverWrites);
+ return { clientPatch, hasClientWrite, serverWrites, unavailableReason, skippedEnvironments };
+}
+
+/** Remove the keys' project overrides so each member inherits its environment value again. */
+export function planScopedSettingsClear(
+ scope: ResolvedSettingsScope,
+ environments: readonly ScopedSettingsEnvironment[],
+ keys: readonly ProjectScopedServerSettingKey[],
+) {
+ const serverWrites =
+ scope.kind === "project" || scope.kind === "checkout"
+ ? projectOverrideWrites(scope, environments, (_current, settings, projectId) =>
+ clearProjectSettingsOverrides(settings, projectId, keys),
+ )
+ : [];
+ return {
+ clientPatch: {} as ClientSettingsPatch,
+ hasClientWrite: false,
+ serverWrites,
+ skippedEnvironments: skippedSettingsEnvironments(
+ scope.environmentIds,
+ environments,
+ serverWrites,
+ ),
+ unavailableReason:
+ serverWrites.length > 0
+ ? null
+ : "Connect the selected checkouts, or update their environments, to reset this override.",
+ };
+}
+
+export interface ProjectOverrideEntry {
+ readonly environmentId: EnvironmentId;
+ readonly projectId: ProjectId;
+}
+
+/**
+ * The projects on the selected environments that override `keys`. An
+ * environment edit leaves these untouched, so the row can name them and
+ * offer to clear them.
+ */
+export function listProjectOverrides(
+ environments: readonly ScopedSettingsEnvironment[],
+ keys: readonly (keyof ServerSettings)[],
+): readonly ProjectOverrideEntry[] {
+ const scoped = keys.filter(isProjectScopedSettingKey);
+ if (scoped.length === 0) return [];
+ return environments.flatMap((environment) => {
+ const overrides = environment.serverConfig?.settings.projectSettingsOverrides;
+ if (!overrides) return [];
+ return Object.entries(overrides).flatMap(([projectId, entry]) =>
+ scoped.some((key) => Object.hasOwn(entry, key))
+ ? [{ environmentId: environment.environmentId, projectId: projectId as ProjectId }]
+ : [],
+ );
+ });
+}
+
+/** Drop `keys` from the named project entries so they follow the environment again. */
+export function planProjectOverridesClear(
+ environments: readonly ScopedSettingsEnvironment[],
+ entries: readonly ProjectOverrideEntry[],
+ keys: readonly ProjectScopedServerSettingKey[],
+) {
+ const byId = new Map(environments.map((environment) => [environment.environmentId, environment]));
+ const writes = new Map();
+ for (const { environmentId, projectId } of entries) {
+ const environment = byId.get(environmentId);
+ if (
+ !environment?.serverConfig ||
+ environment.connection.phase !== "connected" ||
+ environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true
+ )
+ continue;
+ const settings = environment.serverConfig.settings;
+ const existing = writes.get(environmentId);
+ writes.set(environmentId, {
+ environmentId,
+ label: environment.label,
+ patch: {
+ projectSettingsOverrides: {
+ ...existing?.patch.projectSettingsOverrides,
+ [projectId]: clearProjectSettingsOverrides(settings, projectId, keys),
+ },
+ },
+ });
+ }
+ const serverWrites = [...writes.values()];
+ return {
+ clientPatch: {} as ClientSettingsPatch,
+ hasClientWrite: false,
+ serverWrites,
+ skippedEnvironments: skippedSettingsEnvironments(
+ entries.map((entry) => entry.environmentId),
+ environments,
+ serverWrites,
+ ),
+ unavailableReason:
+ serverWrites.length > 0 ? null : "Connect the environments to reset these overrides.",
+ };
+}
+
+/** Wait for every target so a failed environment does not hide successful or later writes. */
+export async function persistScopedSettingsPatch(
+ plan: ReturnType,
+ persistServer: (input: {
+ environmentId: EnvironmentId;
+ input: { patch: ServerSettingsPatch };
+ }) => Promise<{ readonly _tag: "Success" | "Failure" }>,
+ persistClient: (patch: ClientSettingsPatch) => void,
+) {
+ if (plan.hasClientWrite) persistClient(plan.clientPatch);
+ const results = await Promise.allSettled(
+ plan.serverWrites.map(({ environmentId, patch }) =>
+ persistServer({ environmentId, input: { patch } }),
+ ),
+ );
+ const failedWrites = plan.serverWrites.filter((_, index) => {
+ const result = results[index];
+ return result?.status !== "fulfilled" || result.value._tag === "Failure";
+ });
+ return {
+ failedEnvironments: [...failedWrites, ...plan.skippedEnvironments],
+ skippedEnvironments: plan.skippedEnvironments,
+ savedEnvironmentCount: plan.serverWrites.length - failedWrites.length,
+ };
+}
diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx
index 540398c3a..57f23dd5b 100644
--- a/apps/web/src/components/settings/settingsLayout.tsx
+++ b/apps/web/src/components/settings/settingsLayout.tsx
@@ -1,4 +1,6 @@
import { InfoIcon, Undo2Icon } from "lucide-react";
+import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
+import * as Equal from "effect/Equal";
import { useLocation, useNavigate } from "@tanstack/react-router";
import {
createContext,
@@ -19,6 +21,21 @@ import { cn } from "../../lib/utils";
import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import { useOptionalSettingsScope } from "./SettingsScopeContext";
+import {
+ isProjectScopedSettingKey,
+ listProjectOverrides,
+ scopedSettingsAreMixed,
+ scopedSettingsSource,
+} from "./scopedSettings";
+import { useClearProjectOverrides, useClearScopedSettings } from "./useScopedSettings";
+import {
+ SettingInheritance,
+ type SettingInheritanceState,
+ type SettingOverridingProject,
+} from "./SettingInheritance";
+
+const EMPTY_SETTING_KEYS: readonly (keyof ServerSettings)[] = [];
declare module "@tanstack/react-router" {
interface HistoryState {
@@ -253,8 +270,11 @@ export function SettingsRow({
description,
status,
resetAction,
+ onResetOverride,
control,
serverScoped = false,
+ settingKeys = EMPTY_SETTING_KEYS,
+ mixed: mixedOverride,
children,
className,
...rowProps
@@ -263,37 +283,152 @@ export function SettingsRow({
description?: ReactNode;
status?: ReactNode;
resetAction?: ReactNode;
+ /** Replaces the default override clear for rows with side effects beyond the settings key. */
+ onResetOverride?: () => void;
control?: ReactNode;
serverScoped?: boolean;
+ settingKeys?: readonly (keyof ServerSettings)[];
+ mixed?: boolean;
children?: ReactNode;
}) {
const targetRef = useSettingsSearchTarget(rowProps.id);
const primarySettingsAvailable = usePrimarySettingsAvailable();
- const unavailable = serverScoped && !primarySettingsAvailable;
- const renderedReset = unavailable ? null : resetAction;
+ const context = useOptionalSettingsScope();
+ const clearOverrides = useClearScopedSettings();
+ const clearProjectOverrides = useClearProjectOverrides();
+ const isProjectScope =
+ context !== null && (context.scope.kind === "project" || context.scope.kind === "checkout");
+ const scopedKeys = settingKeys.filter(isProjectScopedSettingKey);
+ // A project scope can only edit keys that support overrides; the rest stay
+ // visible so the user sees the inherited value, but cannot change it here.
+ const environmentWide = isProjectScope && serverScoped && scopedKeys.length === 0;
+ const mixed =
+ mixedOverride ?? (context !== null && scopedSettingsAreMixed(context.targets, settingKeys));
+ const source =
+ context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null;
+ const unavailable =
+ serverScoped &&
+ !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable);
+ const inheritedFrom =
+ source === "environment" && context?.scope.environmentIds.length === 1
+ ? (context.environments.find(
+ (environment) => environment.environmentId === context.scope.environmentIds[0],
+ )?.label ?? "environment")
+ : "environment";
+ const environmentSettingsById = useMemo(
+ () =>
+ new Map(
+ (context?.connectedEnvironments ?? []).flatMap((environment) =>
+ environment.serverConfig
+ ? [[environment.environmentId, environment.serverConfig.settings] as const]
+ : [],
+ ),
+ ),
+ [context?.connectedEnvironments],
+ );
+ // At environment scope, projects with their own value keep it when the
+ // environment default changes; the chain names them and can reset them.
+ const overridingProjects = useMemo((): SettingOverridingProject[] => {
+ if (context === null || isProjectScope || scopedKeys.length === 0) return [];
+ return listProjectOverrides(context.connectedEnvironments, scopedKeys).flatMap((entry) => {
+ const group = context.groups.find((candidate) =>
+ candidate.memberProjects.some(
+ (member) => member.environmentId === entry.environmentId && member.id === entry.projectId,
+ ),
+ );
+ if (!group) return [];
+ return [
+ {
+ ...entry,
+ label: group.displayName,
+ open: () =>
+ context.selectScope({
+ project: group.projectKey,
+ ...(context.search.machine ? { machine: context.search.machine } : {}),
+ }),
+ },
+ ];
+ });
+ }, [context, isProjectScope, scopedKeys]);
+ const renderedReset = unavailable ? null : isProjectScope && scopedKeys.length > 0 ? (
+ source === "project" || source === "mixed" ? (
+ (onResetOverride ? onResetOverride() : clearOverrides(scopedKeys))}
+ />
+ ) : null
+ ) : (
+ resetAction
+ );
+ const inertControl = (message: string) => (
+
+
+ }
+ >
+
+ {control}
+
+
+
+ {message}
+
+
+ );
+ // A mixed selection keeps the real control with "Mixed" as its placeholder
+ // (the multi-selection inspector convention): the popover shows who has
+ // what, and picking a value applies it to every target.
const renderedControl =
- unavailable && control ? (
-
-
- }
- >
-
- {control}
-
-
-
- {PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE}
-
-
- ) : (
- control
+ unavailable && control
+ ? inertControl(
+ context
+ ? "Reconnect the selected environment to change this setting."
+ : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
+ )
+ : environmentWide && control
+ ? inertControl("Environment-wide setting. Select an environment to change it.")
+ : control;
+ // Server rows get an indicator beside the title that opens the resolution
+ // chain per target at every scope; client rows keep a plain status only.
+ const customized =
+ context !== null &&
+ settingKeys.some((key) =>
+ context.targets.some((candidate) => {
+ const environmentSettings = environmentSettingsById.get(candidate.environmentId);
+ return (
+ environmentSettings !== undefined &&
+ !Equal.equals(environmentSettings[key], DEFAULT_SERVER_SETTINGS[key])
+ );
+ }),
);
+ const inheritance: { state: SettingInheritanceState; summary: string } = mixed
+ ? { state: "mixed", summary: "Mixed across selected environments" }
+ : source === "project"
+ ? { state: "overridden", summary: "Overridden for this project" }
+ : source === "environment" && scopedKeys.length > 0
+ ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` }
+ : customized
+ ? { state: "environment", summary: "Set on the environment" }
+ : { state: "default", summary: "Built-in default" };
+ const renderedInheritance =
+ context && serverScoped && settingKeys.length > 0 ? (
+ clearProjectOverrides(entries, scopedKeys)}
+ />
+ ) : null;
+ const renderedStatus = status;
return (
{title}
+ {renderedInheritance ? (
+
+ {renderedInheritance}
+
+ ) : null}
{renderedReset}
@@ -320,7 +460,9 @@ export function SettingsRow({
{description}
) : null}
- {status ?
{status}
: null}
+ {renderedStatus ? (
+
{renderedStatus}
+ ) : null}
{renderedControl ? (
diff --git a/apps/web/src/components/settings/settingsScope.test.ts b/apps/web/src/components/settings/settingsScope.test.ts
new file mode 100644
index 000000000..0199f6eb6
--- /dev/null
+++ b/apps/web/src/components/settings/settingsScope.test.ts
@@ -0,0 +1,205 @@
+import { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import type {
+ SidebarProjectGroupMember,
+ SidebarProjectSnapshot,
+} from "../../sidebarProjectGrouping";
+import { resolveSettingsScope, validateSettingsScopeSearch } from "./settingsScope";
+
+const laptopId = EnvironmentId.make("laptop");
+const serverId = EnvironmentId.make("server");
+const environments = [
+ { environmentId: laptopId, label: "Laptop" },
+ { environmentId: serverId, label: "Server" },
+];
+
+function member(id: string, environmentId: EnvironmentId): SidebarProjectGroupMember {
+ return {
+ id: ProjectId.make(id),
+ environmentId,
+ title: "T3 Code",
+ workspaceRoot: `/repos/${id}`,
+ physicalProjectKey: `${environmentId}:/repos/${id}`,
+ environmentLabel:
+ environments.find((environment) => environment.environmentId === environmentId)?.label ??
+ null,
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: "2026-09-07T00:00:00.000Z",
+ updatedAt: "2026-09-07T00:00:00.000Z",
+ };
+}
+
+const first = member("first", laptopId);
+const second = member("second", laptopId);
+const third = member("third", serverId);
+const other = member("other", serverId);
+
+function group(
+ projectKey: string,
+ members: readonly SidebarProjectGroupMember[],
+): SidebarProjectSnapshot {
+ return {
+ ...members[0]!,
+ projectKey,
+ displayName: projectKey,
+ memberProjects: members,
+ memberProjectRefs: members.map((project) => ({
+ environmentId: project.environmentId,
+ projectId: project.id,
+ })),
+ groupedProjectCount: members.length,
+ environmentPresence: "mixed",
+ allRemoteMembersAreDesktopLocal: false,
+ allRemoteMembersAreWsl: false,
+ remoteEnvironmentLabels: [],
+ };
+}
+
+const groups = [group("t3code", [first, second, third]), group("other", [other])];
+
+describe("settings scope search", () => {
+ it("ignores the retired scope key from older links", () => {
+ expect(validateSettingsScopeSearch({ scope: "device", project: "t3code" })).toEqual({
+ project: "t3code",
+ });
+ expect(validateSettingsScopeSearch({ scope: "all" })).toEqual({});
+ });
+
+ it("retains legacy project and machine links without inventing an explicit broad scope", () => {
+ expect(
+ validateSettingsScopeSearch({ project: "t3code", machine: laptopId, unused: true }),
+ ).toEqual({
+ project: "t3code",
+ machine: laptopId,
+ });
+ });
+
+ it("retains an orphan checkout so it cannot turn into all environments", () => {
+ const search = validateSettingsScopeSearch({ checkout: first.physicalProjectKey });
+ expect(search).toEqual({ checkout: first.physicalProjectKey });
+ expect(resolveSettingsScope(search, groups, environments)).toMatchObject({
+ kind: "unavailable",
+ reason: "project-required",
+ members: [],
+ environmentIds: [],
+ });
+ });
+});
+
+describe("settings scope resolution", () => {
+ it("defaults to every environment with no project", () => {
+ expect(resolveSettingsScope({}, groups, environments)).toMatchObject({
+ kind: "all",
+ members: [],
+ environmentIds: [laptopId, serverId],
+ });
+ });
+
+ it("resolves one environment without targeting its project overrides", () => {
+ expect(resolveSettingsScope({ machine: serverId }, groups, environments)).toMatchObject({
+ kind: "environment",
+ environmentId: serverId,
+ environmentIds: [serverId],
+ members: [],
+ });
+ });
+
+ it("keeps all physical members in a project aggregate, including several on one environment", () => {
+ expect(resolveSettingsScope({ project: "t3code" }, groups, environments)).toMatchObject({
+ kind: "project",
+ environmentId: null,
+ members: [first, second, third],
+ environmentIds: [laptopId, serverId],
+ });
+ });
+
+ it("preserves legacy project plus machine aggregates with multiple checkouts", () => {
+ expect(
+ resolveSettingsScope({ project: "t3code", machine: laptopId }, groups, environments),
+ ).toMatchObject({
+ kind: "project",
+ environmentId: laptopId,
+ label: "t3code / Laptop",
+ members: [first, second],
+ environmentIds: [laptopId],
+ });
+ });
+
+ it("narrows a checkout target to exactly one member, deriving its environment when omitted", () => {
+ expect(
+ resolveSettingsScope(
+ { project: "t3code", checkout: second.physicalProjectKey },
+ groups,
+ environments,
+ ),
+ ).toMatchObject({
+ kind: "checkout",
+ checkout: second,
+ environmentId: laptopId,
+ label: "t3code / Laptop · /repos/second",
+ members: [second],
+ environmentIds: [laptopId],
+ });
+ });
+
+ it.each([
+ { project: "missing" },
+ { machine: "removed" },
+ { project: "t3code", machine: "removed" },
+ { project: "t3code", checkout: "deleted" },
+ { project: "other", machine: laptopId },
+ { project: "other", checkout: first.physicalProjectKey },
+ { project: "t3code", machine: serverId, checkout: first.physicalProjectKey },
+ ])("never widens an invalid or stale target: %j", (search) => {
+ expect(resolveSettingsScope(search, groups, environments)).toMatchObject({
+ kind: "unavailable",
+ members: [],
+ environmentIds: [],
+ });
+ });
+
+ it("leaves a removed checkout unavailable while sibling checkouts remain", () => {
+ const search = {
+ project: "t3code",
+ machine: laptopId,
+ checkout: first.physicalProjectKey,
+ };
+ expect(resolveSettingsScope(search, groups, environments)).toMatchObject({
+ kind: "checkout",
+ members: [first],
+ });
+ expect(
+ resolveSettingsScope(search, [group("t3code", [second, third])], environments),
+ ).toMatchObject({ kind: "unavailable", members: [], environmentIds: [] });
+ });
+
+ it("does not select another environment after removing a project's last local checkout", () => {
+ const search = { project: "t3code", machine: laptopId };
+ expect(resolveSettingsScope(search, groups, environments)).toMatchObject({
+ kind: "project",
+ members: [first, second],
+ });
+ expect(resolveSettingsScope(search, [group("t3code", [third])], environments)).toMatchObject({
+ kind: "unavailable",
+ members: [],
+ environmentIds: [],
+ });
+ });
+
+ it("rejects a cached checkout whose environment was removed", () => {
+ expect(
+ resolveSettingsScope(
+ { project: "t3code", checkout: third.physicalProjectKey },
+ groups,
+ environments.slice(0, 1),
+ ),
+ ).toMatchObject({
+ kind: "unavailable",
+ reason: "environment-missing",
+ members: [],
+ environmentIds: [],
+ });
+ });
+});
diff --git a/apps/web/src/components/settings/settingsScope.ts b/apps/web/src/components/settings/settingsScope.ts
new file mode 100644
index 000000000..efa53d7a3
--- /dev/null
+++ b/apps/web/src/components/settings/settingsScope.ts
@@ -0,0 +1,156 @@
+import type { EnvironmentId } from "@t3tools/contracts";
+
+import type {
+ SidebarProjectGroupMember,
+ SidebarProjectSnapshot,
+} from "../../sidebarProjectGrouping";
+import type { EnvironmentPresentation } from "../../state/environments";
+
+/**
+ * Two axes. `machine` narrows the environment axis (absent = all
+ * environments); `project` and `checkout` narrow the project axis (absent =
+ * environment defaults). Device-local preferences are not a scope: they
+ * render regardless of the selection because they never touch a server.
+ */
+export interface SettingsScopeSearch {
+ project?: string | undefined;
+ machine?: string | undefined;
+ checkout?: string | undefined;
+}
+
+type ScopeTargets = {
+ label: string;
+ members: readonly SidebarProjectGroupMember[];
+ environmentIds: readonly EnvironmentId[];
+};
+
+export type ResolvedSettingsScope = ScopeTargets &
+ (
+ | { kind: "all" }
+ | { kind: "environment"; environmentId: EnvironmentId }
+ | {
+ kind: "project";
+ group: SidebarProjectSnapshot;
+ environmentId: EnvironmentId | null;
+ }
+ | {
+ kind: "checkout";
+ group: SidebarProjectSnapshot;
+ checkout: SidebarProjectGroupMember;
+ environmentId: EnvironmentId;
+ }
+ | {
+ kind: "unavailable";
+ reason: "project-required" | "project-missing" | "environment-missing" | "checkout-missing";
+ message: string;
+ }
+ );
+
+/** Stale IDs remain visible to the resolver so a removed target reads as unavailable, not as "all". */
+export function validateSettingsScopeSearch(raw: Record
): SettingsScopeSearch {
+ const stringValue = (value: unknown) =>
+ typeof value === "string" && value.trim().length > 0 ? value : undefined;
+ const project = stringValue(raw.project);
+ const machine = stringValue(raw.machine);
+ const checkout = stringValue(raw.checkout);
+ return {
+ ...(project === undefined ? {} : { project }),
+ ...(machine === undefined ? {} : { machine }),
+ ...(checkout === undefined ? {} : { checkout }),
+ };
+}
+
+/** Resolves only existing targets. An unavailable selection never broadens a subsequent write. */
+export function resolveSettingsScope(
+ search: SettingsScopeSearch,
+ groups: readonly SidebarProjectSnapshot[],
+ environments: readonly Pick[],
+): ResolvedSettingsScope {
+ const unavailable = (
+ reason: Extract["reason"],
+ message: string,
+ ): ResolvedSettingsScope => ({
+ kind: "unavailable",
+ reason,
+ label: "Unavailable selection",
+ message,
+ members: [],
+ environmentIds: [],
+ });
+
+ if (search.checkout && !search.project) {
+ return unavailable("project-required", "Select a project to choose one of its checkouts.");
+ }
+
+ const environment = environments.find((candidate) => candidate.environmentId === search.machine);
+ if (search.machine && !environment) {
+ return unavailable("environment-missing", "This environment is no longer available.");
+ }
+
+ if (search.project) {
+ const group = groups.find((candidate) => candidate.projectKey === search.project);
+ if (!group) return unavailable("project-missing", "This project is no longer available.");
+ const members = group.memberProjects.filter(
+ (member) =>
+ (search.machine === undefined || member.environmentId === search.machine) &&
+ (search.checkout === undefined || member.physicalProjectKey === search.checkout),
+ );
+ if (members.length === 0) {
+ return unavailable(
+ "checkout-missing",
+ search.checkout
+ ? "This checkout is no longer available in the selected project and environment."
+ : "This project has no checkout on this environment.",
+ );
+ }
+ if (search.checkout) {
+ const checkout = members[0]!;
+ const checkoutEnvironment = environments.find(
+ (candidate) => candidate.environmentId === checkout.environmentId,
+ );
+ if (!checkoutEnvironment) {
+ return unavailable(
+ "environment-missing",
+ "This checkout's environment is no longer available.",
+ );
+ }
+ const sharesEnvironment = group.memberProjects.some(
+ (member) =>
+ member.environmentId === checkout.environmentId &&
+ member.physicalProjectKey !== checkout.physicalProjectKey,
+ );
+ return {
+ kind: "checkout",
+ group,
+ checkout,
+ environmentId: checkout.environmentId,
+ label: `${group.displayName} / ${checkoutEnvironment.label}${sharesEnvironment ? ` · ${checkout.workspaceRoot}` : ""}`,
+ members,
+ environmentIds: [checkout.environmentId],
+ };
+ }
+ return {
+ kind: "project",
+ group,
+ environmentId: environment?.environmentId ?? null,
+ label: `${group.displayName} / ${environment?.label ?? "All checkouts"}`,
+ members,
+ environmentIds: [...new Set(members.map((member) => member.environmentId))],
+ };
+ }
+ if (environment) {
+ return {
+ kind: "environment",
+ environmentId: environment.environmentId,
+ label: environment.label,
+ members: [],
+ environmentIds: [environment.environmentId],
+ };
+ }
+ return {
+ kind: "all",
+ label: "All environments",
+ members: [],
+ environmentIds: environments.map((candidate) => candidate.environmentId),
+ };
+}
diff --git a/apps/web/src/components/settings/settingsScopeAxis.test.ts b/apps/web/src/components/settings/settingsScopeAxis.test.ts
new file mode 100644
index 000000000..54a87dc95
--- /dev/null
+++ b/apps/web/src/components/settings/settingsScopeAxis.test.ts
@@ -0,0 +1,88 @@
+import { EnvironmentId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ environmentAxisValue,
+ projectAxisValue,
+ selectEnvironmentAxis,
+ selectProjectAxis,
+ settingsScopeEnvironmentLabel,
+} from "./settingsScopeAxis";
+
+const first = {
+ environmentId: EnvironmentId.make("first"),
+ label: "Development",
+ displayUrl: "https://first.example.com",
+};
+const second = {
+ environmentId: EnvironmentId.make("second"),
+ label: "Development",
+ displayUrl: "https://second.example.com",
+};
+
+describe("settings scope environment labels", () => {
+ it("distinguishes same-name environments by address", () => {
+ const environments = [first, second];
+ expect(
+ environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)),
+ ).toEqual([
+ "Development · https://first.example.com",
+ "Development · https://second.example.com",
+ ]);
+ });
+
+ it("falls back to environment IDs when duplicate names have no display URL", () => {
+ const environments = [first, second].map((environment) => ({
+ ...environment,
+ displayUrl: null,
+ }));
+ expect(
+ environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)),
+ ).toEqual(["Development · first", "Development · second"]);
+ });
+
+ it("keeps unique names compact and removes disambiguation after a rename", () => {
+ expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development");
+ expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe(
+ "Development",
+ );
+ });
+});
+
+describe("settings scope axes", () => {
+ it("maps each axis to its search key and back", () => {
+ expect(projectAxisValue({})).toBe("all");
+ expect(projectAxisValue({ project: "app" })).toBe("app");
+ expect(selectProjectAxis({ machine: "second" }, "app")).toEqual({
+ project: "app",
+ machine: "second",
+ });
+ expect(selectProjectAxis({ machine: "second", project: "app" }, "all")).toEqual({
+ machine: "second",
+ });
+ expect(selectEnvironmentAxis({ project: "app" }, "first")).toEqual({
+ project: "app",
+ machine: "first",
+ });
+ expect(selectEnvironmentAxis({ project: "app", machine: "first" }, "all")).toEqual({
+ project: "app",
+ });
+ });
+
+ it("drops a checkout narrowing from older links when either axis changes", () => {
+ const checkout = { project: "app", checkout: "app@first", machine: "first" };
+ expect(selectEnvironmentAxis(checkout, "second")).toEqual({
+ project: "app",
+ machine: "second",
+ });
+ expect(selectProjectAxis(checkout, "app")).toEqual({ project: "app", machine: "first" });
+ });
+});
+
+describe("environmentAxisValue", () => {
+ it("shows the checkout's environment for a legacy checkout link", () => {
+ expect(environmentAxisValue({ project: "p", checkout: "c" }, "laptop")).toBe("laptop");
+ expect(environmentAxisValue({ project: "p" }, null)).toBe("all");
+ expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk");
+ });
+});
diff --git a/apps/web/src/components/settings/settingsScopeAxis.ts b/apps/web/src/components/settings/settingsScopeAxis.ts
new file mode 100644
index 000000000..166a31a40
--- /dev/null
+++ b/apps/web/src/components/settings/settingsScopeAxis.ts
@@ -0,0 +1,55 @@
+import type { EnvironmentPresentation } from "../../state/environments";
+import type { SettingsScopeSearch } from "./settingsScope";
+
+type ScopeEnvironment = Pick;
+
+export function settingsScopeEnvironmentLabel(
+ environment: ScopeEnvironment,
+ environments: readonly ScopeEnvironment[],
+) {
+ const duplicate = environments.some(
+ (other) =>
+ other.environmentId !== environment.environmentId && other.label === environment.label,
+ );
+ return duplicate
+ ? `${environment.label} · ${environment.displayUrl ?? environment.environmentId}`
+ : environment.label;
+}
+
+export const ALL_ENVIRONMENTS_VALUE = "all";
+export const ALL_PROJECTS_VALUE = "all";
+
+/**
+ * The environment axis: `all` or an environment id. A legacy checkout link
+ * without `machine` still names one environment, which the resolver supplies.
+ */
+export function environmentAxisValue(
+ search: SettingsScopeSearch,
+ resolvedEnvironmentId?: string | null,
+): string {
+ return search.machine ?? resolvedEnvironmentId ?? ALL_ENVIRONMENTS_VALUE;
+}
+
+/** The project axis: `all` or a project key. */
+export function projectAxisValue(search: SettingsScopeSearch): string {
+ return search.project ?? ALL_PROJECTS_VALUE;
+}
+
+/** Choosing an environment keeps the project; a pre-existing checkout narrowing is dropped. */
+export function selectEnvironmentAxis(
+ search: SettingsScopeSearch,
+ value: string,
+): SettingsScopeSearch {
+ const next: SettingsScopeSearch = {};
+ if (search.project) next.project = search.project;
+ if (value !== ALL_ENVIRONMENTS_VALUE) next.machine = value;
+ return next;
+}
+
+/** Choosing a project keeps the environment axis. */
+export function selectProjectAxis(search: SettingsScopeSearch, value: string): SettingsScopeSearch {
+ const next: SettingsScopeSearch = {};
+ if (value !== ALL_PROJECTS_VALUE) next.project = value;
+ if (search.machine) next.machine = search.machine;
+ return next;
+}
diff --git a/apps/web/src/components/settings/settingsScopeNavigation.test.ts b/apps/web/src/components/settings/settingsScopeNavigation.test.ts
new file mode 100644
index 000000000..a613f3adf
--- /dev/null
+++ b/apps/web/src/components/settings/settingsScopeNavigation.test.ts
@@ -0,0 +1,247 @@
+import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ redirect,
+} from "@tanstack/react-router";
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveSettingsScope } from "./settingsScope";
+import { retainSettingsScope, validateSettingsRouteSearch } from "./settingsScopeNavigation";
+
+const checkoutSearch = {
+ project: "repository:t3code",
+ machine: "remote-server",
+ checkout: "remote-server:/home/user/T3 Code",
+};
+
+function createSettingsRouter(initialEntry = "/settings/general") {
+ const root = createRootRoute();
+ const settings = createRoute({
+ getParentRoute: () => root,
+ path: "settings",
+ validateSearch: validateSettingsRouteSearch,
+ search: { middlewares: [retainSettingsScope] },
+ beforeLoad: ({ location }) => {
+ if (location.pathname === "/settings") {
+ throw redirect({ to: "/settings/general", replace: true });
+ }
+ },
+ });
+ const general = createRoute({ getParentRoute: () => settings, path: "general" });
+ const projects = createRoute({ getParentRoute: () => settings, path: "projects" });
+ const integrations = createRoute({ getParentRoute: () => settings, path: "integrations" });
+ const sourceControl = createRoute({ getParentRoute: () => settings, path: "source-control" });
+ const providers = createRoute({
+ getParentRoute: () => settings,
+ path: "providers",
+ validateSearch: (raw: Record) => ({
+ ...(typeof raw.environmentId === "string" && raw.environmentId.trim()
+ ? { environmentId: EnvironmentId.make(raw.environmentId) }
+ : {}),
+ ...(typeof raw.instanceId === "string" && raw.instanceId.trim()
+ ? { instanceId: ProviderInstanceId.make(raw.instanceId) }
+ : {}),
+ }),
+ });
+ const legacyProject = createRoute({
+ getParentRoute: () => root,
+ path: "projects/$projectKey",
+ beforeLoad: ({ params }) => {
+ throw redirect({
+ to: "/settings/projects",
+ search: { project: params.projectKey, machine: undefined },
+ replace: true,
+ });
+ },
+ });
+ return createRouter({
+ routeTree: root.addChildren([
+ settings.addChildren([general, projects, integrations, sourceControl, providers]),
+ legacyProject,
+ ]),
+ history: createMemoryHistory({ initialEntries: [initialEntry] }),
+ });
+}
+
+describe("settings scope navigation", () => {
+ it("replaces the default scope with an explicit environment, then replaces it with a project", async () => {
+ const router = createSettingsRouter();
+ await router.load();
+ await router.navigate({
+ to: "/settings/general",
+ search: { machine: "remote-server" },
+ hash: "",
+ });
+ expect(router.state.location.search).toEqual({ machine: "remote-server" });
+ await router.navigate({ to: "/settings/projects", search: { project: "another-project" } });
+ expect(router.state.location.search).toEqual({ project: "another-project" });
+ });
+
+ it("clears a checkout when selecting all environments and all projects", async () => {
+ const router = createSettingsRouter();
+ await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "old-setting" });
+ // The scope selects send every axis explicitly so "all" does not read as "unchanged".
+ await router.navigate({
+ to: "/settings/general",
+ search: { project: undefined, machine: undefined, checkout: undefined },
+ hash: "",
+ });
+ expect(router.state.location.search).toEqual({});
+ expect(router.state.location.hash).toBe("");
+ });
+
+ it("preserves the checkout through category and settings-search navigation", async () => {
+ const router = createSettingsRouter();
+ await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "new-threads" });
+ await router.navigate({ to: "/settings/integrations", hash: "", replace: true });
+ expect(router.state.location.search).toEqual(checkoutSearch);
+ expect(router.state.location.hash).toBe("");
+ await router.navigate({ to: "/settings/source-control", hash: "source-control-writing-style" });
+ expect(router.state.location.search).toEqual(checkoutSearch);
+ expect(router.state.location.hash).toBe("source-control-writing-style");
+ await router.navigate({ to: "/settings/projects", hash: "project-defaults" });
+ expect(router.state.location.search).toEqual(checkoutSearch);
+ });
+
+ it.each(["/settings/projects", "/settings/integrations", "/settings/source-control"] as const)(
+ "keeps %s when regrouping or selecting a target from the shared settings layout",
+ async (to) => {
+ const router = createSettingsRouter();
+ await router.navigate({ to, search: checkoutSearch });
+
+ const regroupedCheckout = { ...checkoutSearch, project: "separate:t3code" };
+ await router.navigate({
+ from: "/settings",
+ to: router.state.location.pathname,
+ search: () => regroupedCheckout,
+ replace: true,
+ hashScrollIntoView: false,
+ });
+ expect(router.state.location.pathname).toBe(to);
+ expect(router.state.location.search).toEqual(regroupedCheckout);
+ expect(router.state.redirect).toBeUndefined();
+
+ await router.navigate({
+ from: "/settings",
+ to: router.state.location.pathname,
+ search: () => ({ machine: "another-server" }),
+ hash: "",
+ resetScroll: false,
+ });
+ expect(router.state.location.pathname).toBe(to);
+ expect(router.state.location.search).toEqual({ machine: "another-server" });
+ expect(router.state.location.hash).toBe("");
+
+ const selectedCheckout = {
+ project: "another-project",
+ machine: "another-server",
+ checkout: "another-server:/home/user/Another checkout",
+ };
+ await router.navigate({
+ from: "/settings",
+ to: router.state.location.pathname,
+ search: () => selectedCheckout,
+ hash: "requested-setting",
+ });
+ expect(router.state.location.pathname).toBe(to);
+ expect(router.state.location.search).toEqual(selectedCheckout);
+ expect(router.state.location.hash).toBe("requested-setting");
+ },
+ );
+
+ it("honors an explicit provider environment and drops its instance on category navigation", async () => {
+ const router = createSettingsRouter();
+ await router.navigate({ to: "/settings/general", search: checkoutSearch });
+ await router.navigate({
+ to: "/settings/providers",
+ search: {
+ environmentId: EnvironmentId.make("provider-server"),
+ instanceId: ProviderInstanceId.make("codex-work"),
+ },
+ });
+ expect(router.state.location.search).toEqual({
+ machine: "provider-server",
+ environmentId: "provider-server",
+ instanceId: "codex-work",
+ });
+ await router.navigate({ to: "/settings/general", hash: "" });
+ expect(router.state.location.search).toEqual({ machine: "provider-server" });
+ });
+
+ it("preserves the environment from an initially loaded legacy provider URL", async () => {
+ const router = createSettingsRouter(
+ "/settings/providers?environmentId=provider-server&instanceId=codex-work",
+ );
+ await router.load();
+ await router.navigate({ to: "/settings/general" });
+ expect(router.state.location.search).toEqual({ machine: "provider-server" });
+ });
+
+ it("retains an explicit unavailable target rather than reviving the previous environment", async () => {
+ const router = createSettingsRouter();
+ await router.navigate({ to: "/settings/general", search: { machine: "online" } });
+ await router.navigate({ to: "/settings/general", search: { machine: "removed" } });
+ expect(router.state.location.search).toEqual({ machine: "removed" });
+ expect(
+ resolveSettingsScope(
+ router.state.location.search,
+ [],
+ [{ environmentId: EnvironmentId.make("online"), label: "Online" }],
+ ),
+ ).toMatchObject({
+ kind: "unavailable",
+ reason: "environment-missing",
+ environmentIds: [],
+ });
+ });
+
+ it("respects explicit clearing keys instead of restoring the previous checkout", async () => {
+ const router = createSettingsRouter();
+ await router.navigate({ to: "/settings/general", search: checkoutSearch });
+ await router.navigate({
+ to: "/settings/projects",
+ search: { project: "different-project", machine: undefined },
+ });
+ expect(router.state.location.search).toEqual({ project: "different-project" });
+ });
+
+ it("preserves escaped checkout identifiers across reload and browser history", async () => {
+ const router = createSettingsRouter();
+ await router.navigate({ to: "/settings/general", search: checkoutSearch });
+ const checkoutHref = router.state.location.href;
+ await router.navigate({ to: "/settings/general", search: { machine: "another" } });
+ router.history.back();
+ await router.load();
+ expect(router.state.location.search).toEqual(checkoutSearch);
+ const reloaded = createSettingsRouter(checkoutHref);
+ await reloaded.load();
+ expect(reloaded.state.location.search).toEqual(checkoutSearch);
+ await reloaded.navigate({ to: "/settings/integrations", hash: "agent-browser-access" });
+ expect(reloaded.state.location.search).toEqual(checkoutSearch);
+ });
+
+ it("redirects legacy project links without carrying the prior checkout scope", async () => {
+ const router = createSettingsRouter();
+ await router.navigate({ to: "/settings/general", search: checkoutSearch });
+ await router.navigate({
+ to: "/projects/$projectKey",
+ params: { projectKey: "legacy-project" },
+ });
+ expect(router.state.redirect).not.toBeUndefined();
+ await router.navigate(router.state.redirect!.options);
+ expect(router.state.location.pathname).toBe("/settings/projects");
+ expect(router.state.location.search).toEqual({ project: "legacy-project" });
+ });
+
+ it("keeps scope through the settings index redirect", async () => {
+ const router = createSettingsRouter();
+ await router.navigate({ to: "/settings", search: { machine: "remote-server" } });
+ expect(router.state.redirect).not.toBeUndefined();
+ await router.navigate(router.state.redirect!.options);
+ expect(router.state.location.pathname).toBe("/settings/general");
+ expect(router.state.location.search).toEqual({ machine: "remote-server" });
+ });
+});
diff --git a/apps/web/src/components/settings/settingsScopeNavigation.ts b/apps/web/src/components/settings/settingsScopeNavigation.ts
new file mode 100644
index 000000000..e4f3c70d5
--- /dev/null
+++ b/apps/web/src/components/settings/settingsScopeNavigation.ts
@@ -0,0 +1,32 @@
+import type { SearchMiddleware } from "@tanstack/react-router";
+
+import { validateSettingsScopeSearch, type SettingsScopeSearch } from "./settingsScope";
+
+/** Accept legacy provider links without replacing an explicit settings scope. */
+export function validateSettingsRouteSearch(raw: Record) {
+ return validateSettingsScopeSearch(
+ typeof raw.environmentId === "string" && raw.machine === undefined && raw.project === undefined
+ ? { ...raw, machine: raw.environmentId }
+ : raw,
+ );
+}
+
+const SCOPE_KEYS = [
+ "project",
+ "machine",
+ "checkout",
+] as const satisfies readonly (keyof SettingsScopeSearch)[];
+const TARGET_INPUT_KEYS = [...SCOPE_KEYS, "environmentId"];
+
+/**
+ * Category links keep the target, while an explicit target replaces the entire
+ * previous selection. `environmentId` is the legacy provider deep-link target.
+ */
+export const retainSettingsScope: SearchMiddleware = ({ search, next }) => {
+ const result = next(search);
+ if (TARGET_INPUT_KEYS.some((key) => Object.hasOwn(result, key))) return result;
+ const previousScope = Object.fromEntries(
+ SCOPE_KEYS.filter((key) => search[key] !== undefined).map((key) => [key, search[key]]),
+ );
+ return { ...previousScope, ...result };
+};
diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts
index 83de62361..0bc16a6fa 100644
--- a/apps/web/src/components/settings/settingsSearch.test.ts
+++ b/apps/web/src/components/settings/settingsSearch.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, it, vi } from "vite-plus/test";
+import { EnvironmentId } from "@t3tools/contracts";
import {
filterAvailableSettingsSearchItems,
+ getSettingsSearchTargetScope,
+ getThreadAutoSettlementSearchAvailability,
+ isSettingsOverviewVisible,
+ isSettingsSearchScopeAvailable,
searchableSetting,
searchSettings,
SETTINGS_SEARCH_ITEMS,
@@ -141,7 +146,7 @@ describe("searchSettings", () => {
it("hides settings whose controls are unavailable", () => {
const available = filterAvailableSettingsSearchItems({
hasCloudPublicConfig: false,
- hasPrimaryEnvironment: false,
+ hasEnvironment: false,
hasProviderSettingsEnvironment: false,
canManageLocalBackend: false,
isWslSettingsRowVisible: false,
@@ -169,7 +174,7 @@ describe("searchSettings", () => {
it("shows automatic settlement settings when the server supports them", () => {
const available = filterAvailableSettingsSearchItems({
hasCloudPublicConfig: false,
- hasPrimaryEnvironment: false,
+ hasEnvironment: false,
hasProviderSettingsEnvironment: false,
canManageLocalBackend: false,
isWslSettingsRowVisible: false,
@@ -254,4 +259,196 @@ describe("searchSettings", () => {
targetId: "browser-profiles",
});
});
+
+ it.each([
+ ["default model", "default-model", "/settings/general"],
+ ["new threads", "new-threads", "/settings/general"],
+ ["agent browser access", "agent-browser-access", "/settings/integrations"],
+ ["automatically pull", "automatic-pull", "/settings/source-control"],
+ ["actions", "project-actions", "/settings/projects"],
+ ["project overview", "project-overview", "/settings/projects"],
+ ])("routes %s to its owning category", (query, id, to) => {
+ expect(searchSettings(query)[0]).toMatchObject({ id, to });
+ });
+
+ it("keeps environment settings discoverable without a primary environment", () => {
+ const available = filterAvailableSettingsSearchItems({
+ hasCloudPublicConfig: false,
+ hasEnvironment: true,
+ hasProviderSettingsEnvironment: true,
+ canManageLocalBackend: false,
+ isWslSettingsRowVisible: false,
+ hasThreadAutoSettlement: true,
+ });
+ expect(searchSettings("writing style", available)[0]?.id).toBe("source-control-writing-style");
+ expect(searchSettings("auto-settle", available)).toHaveLength(3);
+ });
+});
+
+describe("settings search targets", () => {
+ it.each([
+ "auto-settle-inactive-threads",
+ "auto-settle-merged-threads",
+ "days-before-auto-settle",
+ ])("retains the capability requirement for %s", (targetId) => {
+ expect(getSettingsSearchTargetScope(targetId)).toMatchObject({
+ scope: "project-defaults",
+ requiresThreadAutoSettlement: true,
+ });
+ });
+
+ it("treats device-local rows as reachable from every selection", () => {
+ const setting = getSettingsSearchTargetScope("time-format")!;
+ expect(setting).toEqual({ title: "Time format", scope: null });
+ expect(isSettingsSearchScopeAvailable(setting.scope, "project")).toBe(true);
+ expect(isSettingsSearchScopeAvailable(setting.scope, "all")).toBe(true);
+ expect(getSettingsSearchTargetScope("appearance")).toMatchObject({ scope: null });
+ expect(getSettingsSearchTargetScope("missing-setting")).toBeNull();
+ });
+
+ it.each(["all", "environment", "project", "checkout"] as const)(
+ "makes browser access editable at the %s scope",
+ (kind) => {
+ for (const id of ["agent-browser-access", "agent-device-access"]) {
+ const setting = getSettingsSearchTargetScope(id)!;
+ expect(isSettingsSearchScopeAvailable(setting.scope, kind)).toBe(true);
+ expect(isSettingsSearchScopeAvailable(setting.scope, "unavailable")).toBe(false);
+ }
+ },
+ );
+
+ it("lets project-scopable rows resolve at every server-backed scope", () => {
+ const model = getSettingsSearchTargetScope("text-generation-model")!;
+ expect(isSettingsSearchScopeAvailable(model.scope, "all")).toBe(true);
+ expect(isSettingsSearchScopeAvailable(model.scope, "environment")).toBe(true);
+ expect(isSettingsSearchScopeAvailable(model.scope, "project")).toBe(true);
+ });
+
+ it("reaches source control discovery and git fetch interval from the default scope", () => {
+ for (const id of ["source-control", "git-fetch-interval"]) {
+ const item = getSettingsSearchTargetScope(id)!;
+ expect(isSettingsSearchScopeAvailable(item.scope, "all")).toBe(true);
+ expect(isSettingsSearchScopeAvailable(item.scope, "environment")).toBe(true);
+ expect(isSettingsSearchScopeAvailable(item.scope, "project")).toBe(false);
+ }
+ });
+
+ it("keeps environment-wide settings out of project scopes", () => {
+ const updates = getSettingsSearchTargetScope("provider-update-checks")!;
+ expect(updates.scope).toBe("environment-defaults");
+ expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true);
+ expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true);
+ expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false);
+ const streaming = getSettingsSearchTargetScope("legacy-token-streaming")!;
+ expect(streaming.scope).toBe("project-defaults");
+ expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true);
+ for (const id of ["legacy-plan-mode", "context-window-indicator", "legacy-sidebar"]) {
+ expect(getSettingsSearchTargetScope(id)!.scope).toBeNull();
+ }
+ });
+});
+
+describe("auto-settlement search availability", () => {
+ function environment(id: string, { connected = true, loaded = true, supported = true } = {}) {
+ return {
+ environmentId: EnvironmentId.make(id),
+ connection: { phase: connected ? ("connected" as const) : ("offline" as const) },
+ serverConfig: loaded
+ ? { environment: { capabilities: { threadAutoSettlement: supported } } }
+ : null,
+ };
+ }
+
+ const capable = environment("capable");
+ const unsupported = environment("unsupported", { supported: false });
+ const offline = environment("offline", { connected: false });
+ const loading = environment("loading", { loaded: false });
+ const environments = [capable, unsupported, offline, loading];
+
+ it("keeps results discoverable when one connected environment supports them", () => {
+ const availability = getThreadAutoSettlementSearchAvailability(environments);
+ expect(availability.eligibleEnvironmentIds).toEqual([capable.environmentId]);
+ const items = filterAvailableSettingsSearchItems({
+ hasCloudPublicConfig: false,
+ hasEnvironment: true,
+ hasProviderSettingsEnvironment: true,
+ canManageLocalBackend: false,
+ isWslSettingsRowVisible: false,
+ hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0,
+ });
+ expect(searchSettings("auto-settle", items).map((item) => item.id)).toEqual([
+ "auto-settle-inactive-threads",
+ "auto-settle-merged-threads",
+ "days-before-auto-settle",
+ ]);
+ });
+
+ it("offers only capable environments when an aggregate has mixed capabilities", () => {
+ expect(
+ getThreadAutoSettlementSearchAvailability(environments, {
+ kind: "all",
+ environmentIds: environments.map((entry) => entry.environmentId),
+ }),
+ ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false });
+ });
+
+ it("allows a capable named environment regardless of other environments' capabilities", () => {
+ expect(
+ getThreadAutoSettlementSearchAvailability(environments, {
+ kind: "environment",
+ environmentIds: [capable.environmentId],
+ }).isTargetAvailable,
+ ).toBe(true);
+ });
+
+ it.each([unsupported, offline, loading])(
+ "does not render the target on $environmentId or silently fall back",
+ (selected) => {
+ expect(
+ getThreadAutoSettlementSearchAvailability(environments, {
+ kind: "environment",
+ environmentIds: [selected.environmentId],
+ }),
+ ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false });
+ },
+ );
+
+ it("offers a capable environment instead of a dead target at an unavailable scope", () => {
+ expect(
+ getThreadAutoSettlementSearchAvailability(environments, {
+ kind: "unavailable",
+ environmentIds: [capable.environmentId],
+ }),
+ ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false });
+ });
+
+ it("ignores offline and unloaded targets when all connected targets support the setting", () => {
+ const selected = [capable, offline, loading];
+ expect(
+ getThreadAutoSettlementSearchAvailability(selected, {
+ kind: "all",
+ environmentIds: selected.map((entry) => entry.environmentId),
+ }).isTargetAvailable,
+ ).toBe(true);
+ });
+
+ it("offers no unavailable environments when none can render the setting", () => {
+ const selected = [unsupported, offline, loading];
+ expect(
+ getThreadAutoSettlementSearchAvailability(selected, {
+ kind: "all",
+ environmentIds: selected.map((entry) => entry.environmentId),
+ }),
+ ).toEqual({ eligibleEnvironmentIds: [], isTargetAvailable: false });
+ expect(getThreadAutoSettlementSearchAvailability([]).eligibleEnvironmentIds).toEqual([]);
+ });
+});
+
+describe("settings sidebar scope", () => {
+ it("shows Overview only for project and checkout targets", () => {
+ expect(isSettingsOverviewVisible({})).toBe(false);
+ expect(isSettingsOverviewVisible({ machine: "remote" })).toBe(false);
+ expect(isSettingsOverviewVisible({ project: "project" })).toBe(true);
+ expect(isSettingsOverviewVisible({ project: "project", checkout: "checkout" })).toBe(true);
+ });
});
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 428bb7bb7..a3d45b078 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -1,5 +1,12 @@
import { isElectron } from "~/env";
import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
+import type { EnvironmentId } from "@t3tools/contracts";
+import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+import {
+ validateSettingsScopeSearch,
+ type ResolvedSettingsScope,
+ type SettingsScopeSearch,
+} from "./settingsScope";
export type SettingsPath =
| "/settings/projects"
@@ -13,6 +20,19 @@ export type SettingsPath =
| "/settings/connections"
| "/settings/archived";
+/**
+ * Where a setting can be edited. Device-local rows have no scope: they render
+ * at every selection. `project-defaults` rows accept project overrides, so
+ * they are reachable from any server-backed selection.
+ */
+export type SettingsSearchScope =
+ | "environment"
+ | "environment-defaults"
+ | "project-defaults"
+ | "project"
+ | "checkout"
+ | "connections";
+
export interface SettingsSearchItem {
readonly id: string;
readonly title: string;
@@ -20,6 +40,7 @@ export interface SettingsSearchItem {
readonly targetId?: string;
/** Descriptions, option labels, and aliases people may remember instead of the title. */
readonly searchTerms?: ReadonlyArray;
+ readonly scope?: SettingsSearchScope;
// Its row only renders in the desktop app, so a browser result would land on
// an anchor that isn't there.
readonly desktopOnly?: boolean;
@@ -28,7 +49,7 @@ export interface SettingsSearchItem {
// not expose a result that points to a missing anchor.
readonly windowsOnly?: boolean;
readonly cloudOnly?: boolean;
- readonly primaryOnly?: boolean;
+ readonly environmentOnly?: boolean;
readonly providerSettingsOnly?: boolean;
readonly localBackendManagementOnly?: boolean;
readonly wslAvailableOnly?: boolean;
@@ -37,7 +58,7 @@ export interface SettingsSearchItem {
export interface SettingsSearchAvailability {
readonly hasCloudPublicConfig: boolean;
- readonly hasPrimaryEnvironment: boolean;
+ readonly hasEnvironment: boolean;
readonly hasProviderSettingsEnvironment: boolean;
readonly canManageLocalBackend: boolean;
readonly isWslSettingsRowVisible: boolean;
@@ -49,9 +70,9 @@ export interface SettingsSearchAvailability {
* subtitles both render from this record, so each label exists once.
*/
export const SETTINGS_SECTION_LABELS: Readonly> = {
+ "/settings/projects": "Project",
"/settings/general": "General",
"/settings/appearance": "Appearance",
- "/settings/projects": "Projects",
"/settings/keybindings": "Keybindings",
"/settings/snap-shot": "SnapShots",
"/settings/providers": "Providers",
@@ -70,10 +91,30 @@ export const SETTINGS_SEARCH_ITEMS = [
{
id: "project-defaults",
title: "Project defaults and overrides",
+ to: "/settings/general",
+ scope: "project-defaults",
+ searchTerms: ["model workspace environments projects inheritance checkout"],
+ },
+ {
+ id: "project-overview",
+ title: "Project overview",
to: "/settings/projects",
+ searchTerms: ["name icon emoji image checkout remove delete"],
+ },
+ {
+ id: "default-model",
+ title: "Default model",
+ to: "/settings/general",
+ scope: "project-defaults",
+ searchTerms: ["new thread project provider reasoning effort"],
+ },
+ {
+ id: "default-permissions",
+ title: "Permissions",
+ to: "/settings/general",
+ scope: "project-defaults",
searchTerms: [
- "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts",
- "project name icon emoji favicon image pull request merge method squash rebase",
+ "new thread default runtime mode supervised approvals auto accept edits full access",
],
},
{
@@ -175,6 +216,7 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["sidebar inactivity days no activity automatically"],
requiresThreadAutoSettlement: true,
+ scope: "project-defaults",
},
{
id: "auto-settle-merged-threads",
@@ -182,6 +224,7 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["pull request merge closed automatically sidebar"],
requiresThreadAutoSettlement: true,
+ scope: "project-defaults",
},
{
id: "days-before-auto-settle",
@@ -190,6 +233,7 @@ export const SETTINGS_SEARCH_ITEMS = [
targetId: "auto-settle-inactive-threads",
searchTerms: ["thread timeout activity sidebar"],
requiresThreadAutoSettlement: true,
+ scope: "project-defaults",
},
{
id: "time-format",
@@ -238,11 +282,13 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Provider update checks",
to: "/settings/general",
searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"],
+ scope: "environment-defaults",
},
{
id: "continue-threads-after-server-update",
title: "Continue threads after restarts",
to: "/settings/general",
+ scope: "project-defaults",
searchTerms: [
"resume running active interrupted work restart reboot machine crash desktop update automatically",
],
@@ -251,6 +297,7 @@ export const SETTINGS_SEARCH_ITEMS = [
id: "background-activity",
title: "Background activity",
to: "/settings/general",
+ scope: "environment-defaults",
searchTerms: [
"balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy",
],
@@ -258,19 +305,22 @@ export const SETTINGS_SEARCH_ITEMS = [
{
id: "new-threads",
title: "New threads",
- to: "/settings/projects",
+ to: "/settings/general",
+ scope: "project-defaults",
searchTerms: ["default workspace mode draft local worktree"],
},
{
id: "start-from-origin",
title: "Start from origin",
to: "/settings/general",
+ scope: "project-defaults",
searchTerms: ["new worktrees latest matching remote branch local"],
},
{
id: "add-project-starts-in",
title: "Add project starts in",
to: "/settings/general",
+ scope: "environment-defaults",
searchTerms: ["base directory folder browser path home"],
},
{
@@ -302,6 +352,7 @@ export const SETTINGS_SEARCH_ITEMS = [
id: "text-generation-model",
title: "Text generation model",
to: "/settings/general",
+ scope: "project-defaults",
searchTerms: ["generated thread titles source control content default provider"],
},
{
@@ -319,6 +370,7 @@ export const SETTINGS_SEARCH_ITEMS = [
id: "legacy-token-streaming",
title: "Stream token by token (legacy)",
to: "/settings/general",
+ scope: "project-defaults",
searchTerms: ["response output old compatibility"],
},
{
@@ -405,8 +457,9 @@ export const SETTINGS_SEARCH_ITEMS = [
{
id: "agent-browser-access",
title: "Agent browser access",
- to: "/settings/projects",
- searchTerms: ["allow open drive preview tools sessions"],
+ to: "/settings/integrations",
+ scope: "project-defaults",
+ searchTerms: ["allow disable enable open drive preview tools sessions project override"],
},
{
id: "device-hosts",
@@ -418,7 +471,7 @@ export const SETTINGS_SEARCH_ITEMS = [
id: "agent-device-access",
title: "Agent device access",
to: "/settings/integrations",
- targetId: "devices",
+ scope: "project-defaults",
searchTerms: ["allow simulator emulator ios android drive tools sessions"],
},
{
@@ -482,10 +535,25 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/integrations",
searchTerms: ["agent opens browser device simulator pop into view hide"],
},
+ {
+ id: "automatic-pull",
+ title: "Automatically pull",
+ to: "/settings/source-control",
+ scope: "project-defaults",
+ searchTerms: ["auto pull default branch current checkout fast forward upstream"],
+ },
+ {
+ id: "pull-request-merge-method",
+ title: "Default merge method",
+ to: "/settings/source-control",
+ scope: "project-defaults",
+ searchTerms: ["pull request merge squash rebase last selected"],
+ },
{
id: "source-control",
title: "Source control",
to: "/settings/source-control",
+ scope: "environment-defaults",
searchTerms: [
"version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment",
],
@@ -497,7 +565,8 @@ export const SETTINGS_SEARCH_ITEMS = [
searchTerms: [
"automatic remote branch refresh background credentials security keys seconds off",
],
- primaryOnly: true,
+ environmentOnly: true,
+ scope: "environment-defaults",
},
{
id: "source-control-writing-style",
@@ -506,14 +575,14 @@ export const SETTINGS_SEARCH_ITEMS = [
searchTerms: [
"repository conventions conventional commits custom instructions change descriptions request titles",
],
- primaryOnly: true,
+ environmentOnly: true,
},
{
id: "follow-change-request-templates",
title: "Follow change request templates",
to: "/settings/source-control",
searchTerms: ["repository pr pull request description structure"],
- primaryOnly: true,
+ environmentOnly: true,
},
{
id: "source-control-writer-model",
@@ -522,7 +591,14 @@ export const SETTINGS_SEARCH_ITEMS = [
searchTerms: [
"override generated commit change request pr titles descriptions branch bookmark",
],
- primaryOnly: true,
+ environmentOnly: true,
+ scope: "project-defaults",
+ },
+ {
+ id: "project-actions",
+ title: "Actions",
+ to: "/settings/projects",
+ searchTerms: ["commands scripts setup run dev server checkout worktree t3.json import"],
},
{
id: "environment-icon",
@@ -612,6 +688,111 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];
const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const));
+const SETTINGS_CATEGORY_SCOPES: Readonly> = {
+ "/settings/projects": "project",
+ "/settings/general": null,
+ "/settings/appearance": null,
+ "/settings/snap-shot": null,
+ // Keybindings fan out to the selection; Providers shows the representative
+ // environment at any selection. Neither needs a particular scope to render.
+ "/settings/keybindings": null,
+ "/settings/providers": null,
+ "/settings/integrations": null,
+ "/settings/source-control": "environment-defaults",
+ "/settings/connections": "connections",
+ "/settings/archived": "project-defaults",
+};
+
+/** Search keeps the selected target. A missing row can explain its owning scope instead. */
+export function getSettingsSearchTargetScope(targetId: string) {
+ const items: readonly SettingsSearchItem[] = SETTINGS_SEARCH_ITEMS;
+ const item =
+ items.find((candidate) => candidate.id === targetId) ??
+ items.find((candidate) => candidate.targetId === targetId);
+ return item
+ ? {
+ title: item.title,
+ scope: item.scope ?? SETTINGS_CATEGORY_SCOPES[item.to],
+ ...(item.requiresThreadAutoSettlement ? { requiresThreadAutoSettlement: true } : {}),
+ }
+ : null;
+}
+
+interface AutoSettlementSearchEnvironment {
+ readonly environmentId: EnvironmentId;
+ readonly connection: { readonly phase: EnvironmentConnectionPhase };
+ readonly serverConfig: {
+ readonly environment: {
+ readonly capabilities: { readonly threadAutoSettlement?: boolean };
+ };
+ } | null;
+}
+
+/** Discovery needs one capable environment; the selected page needs every connected target to support it. */
+export function getThreadAutoSettlementSearchAvailability(
+ environments: readonly AutoSettlementSearchEnvironment[],
+ scope?: Pick,
+) {
+ const connected = environments.filter(
+ (environment) =>
+ environment.connection.phase === "connected" && environment.serverConfig !== null,
+ );
+ const eligibleEnvironmentIds = connected
+ .filter(
+ (environment) =>
+ environment.serverConfig?.environment.capabilities.threadAutoSettlement === true,
+ )
+ .map((environment) => environment.environmentId);
+ const selected = connected.filter((environment) =>
+ scope?.environmentIds.includes(environment.environmentId),
+ );
+ return {
+ eligibleEnvironmentIds,
+ isTargetAvailable:
+ scope !== undefined &&
+ scope.kind !== "unavailable" &&
+ selected.length > 0 &&
+ selected.every((environment) => eligibleEnvironmentIds.includes(environment.environmentId)),
+ };
+}
+
+export function isSettingsSearchScopeAvailable(
+ requiredScope: SettingsSearchScope | null,
+ scopeKind: ResolvedSettingsScope["kind"],
+): boolean {
+ switch (requiredScope) {
+ case null:
+ case "connections":
+ return true;
+ case "environment":
+ case "checkout":
+ return requiredScope === scopeKind;
+ case "project":
+ return scopeKind === "project" || scopeKind === "checkout";
+ case "environment-defaults":
+ return scopeKind === "environment" || scopeKind === "all";
+ case "project-defaults":
+ return (
+ scopeKind === "environment" ||
+ scopeKind === "all" ||
+ scopeKind === "project" ||
+ scopeKind === "checkout"
+ );
+ }
+}
+
+function settingsScopeKindFromSearch(search: SettingsScopeSearch): ResolvedSettingsScope["kind"] {
+ const target = validateSettingsScopeSearch({ ...search });
+ if (target.checkout && !target.project) return "unavailable";
+ if (target.project) return target.checkout ? "checkout" : "project";
+ return target.machine ? "environment" : "all";
+}
+
+export function isSettingsOverviewVisible(search: SettingsScopeSearch): boolean {
+ const kind = settingsScopeKindFromSearch(search);
+ return kind === "project" || kind === "checkout";
+}
+
/**
* `id` and `title` props for the element a search item anchors to. Panels
* spread (or pick from) this instead of restating the strings, so the catalog
@@ -632,7 +813,7 @@ export function filterAvailableSettingsSearchItems(
return items.filter(
(item) =>
(!item.cloudOnly || availability.hasCloudPublicConfig) &&
- (!item.primaryOnly || availability.hasPrimaryEnvironment) &&
+ (!item.environmentOnly || availability.hasEnvironment) &&
(!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
(!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
(!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts
index a2f5ca627..b4a892f45 100644
--- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts
+++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts
@@ -1,23 +1,22 @@
import { useMemo } from "react";
-import { useAtomValue } from "@effect/atom-react";
import { AuthAccessWriteScope } from "@t3tools/contracts";
import { hasCloudPublicConfig } from "~/cloud/publicConfig";
import { isElectron } from "~/env";
import { desktopWslStateAtom } from "~/state/desktopWslState";
-import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
+import { useEnvironments } from "~/state/environments";
import { useEnvironmentQuery } from "~/state/query";
import { usePrimarySessionState } from "~/environments/primary";
-import { primaryServerConfigAtom } from "~/state/server";
import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
-import { filterAvailableSettingsSearchItems } from "./settingsSearch";
+import {
+ filterAvailableSettingsSearchItems,
+ getThreadAutoSettlementSearchAvailability,
+} from "./settingsSearch";
export function useAvailableSettingsSearchItems() {
- const primaryEnvironmentId = usePrimaryEnvironmentId();
const { environments } = useEnvironments();
const primarySessionState = usePrimarySessionState();
- const primaryServerConfig = useAtomValue(primaryServerConfigAtom);
const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
const canManageLocalBackend =
isElectron ||
@@ -29,7 +28,7 @@ export function useAvailableSettingsSearchItems() {
() =>
filterAvailableSettingsSearchItems({
hasCloudPublicConfig: hasCloudPublicConfig(),
- hasPrimaryEnvironment: primaryEnvironmentId !== null,
+ hasEnvironment: environments.some((environment) => environment.serverConfig !== null),
hasProviderSettingsEnvironment: environments.some((environment) =>
isProviderSettingsEnvironmentAvailable({
connectionPhase: environment.connection.phase,
@@ -42,15 +41,8 @@ export function useAvailableSettingsSearchItems() {
error: desktopWsl.error,
}),
hasThreadAutoSettlement:
- primaryServerConfig?.environment.capabilities.threadAutoSettlement === true,
+ getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0,
}),
- [
- canManageLocalBackend,
- desktopWsl.data,
- desktopWsl.error,
- environments,
- primaryEnvironmentId,
- primaryServerConfig,
- ],
+ [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments],
);
}
diff --git a/apps/web/src/components/settings/useProjectScriptSettings.ts b/apps/web/src/components/settings/useProjectScriptSettings.ts
new file mode 100644
index 000000000..c439b4bdf
--- /dev/null
+++ b/apps/web/src/components/settings/useProjectScriptSettings.ts
@@ -0,0 +1,220 @@
+import {
+ isAtomCommandInterrupted,
+ mapAtomCommandResult,
+ squashAtomCommandFailure,
+ type AtomCommandResult,
+} from "@t3tools/client-runtime/state/runtime";
+import {
+ type EnvironmentId,
+ type ProjectId,
+ type ProjectScript,
+ type ResolvedKeybindingsConfig,
+ type ServerSettings,
+ type ExecutionEnvironmentCapabilities,
+} from "@t3tools/contracts";
+import { resolveProjectScripts } from "@t3tools/shared/projectScripts";
+import { clearProjectSettingsOverrides } from "@t3tools/shared/projectSettings";
+import * as Cause from "effect/Cause";
+import { AsyncResult } from "effect/unstable/reactivity";
+import { useRef, useState } from "react";
+
+import { isElectron } from "../../env";
+import {
+ decodeProjectScriptKeybindingRule,
+ keybindingValueForCommand,
+} from "../../lib/projectScriptKeybindings";
+import {
+ buildProjectScript,
+ commandForProjectScript,
+ nextProjectScriptId,
+} from "../../projectScripts";
+import { useProjects } from "../../state/entities";
+import { serverEnvironment } from "../../state/server";
+import { projectEnvironment } from "../../state/projects";
+import { useAtomCommand } from "../../state/use-atom-command";
+import type { NewProjectScriptInput } from "../projectScriptEditor";
+import { toastManager } from "../ui/toast";
+import { resolveProjectScriptsWrite } from "./ProjectSettingsPanel.logic";
+
+function reportScriptFailure(result: AtomCommandResult) {
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add({
+ type: "error",
+ title: "Failed to save project actions",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ }
+ return mapAtomCommandResult(result, () => undefined);
+}
+
+/**
+ * Edits the action list on every target: the environment default when there is
+ * no project, else that project's override entry. Shortcuts follow on desktop.
+ */
+export function useProjectScriptSettings(
+ targets: readonly {
+ environmentId: EnvironmentId;
+ settings: ServerSettings;
+ keybindings: ResolvedKeybindingsConfig;
+ capabilities: ExecutionEnvironmentCapabilities;
+ project?: { id: ProjectId; scripts: readonly ProjectScript[] };
+ }[],
+) {
+ const projects = useProjects();
+ const [saving, setSaving] = useState(false);
+ const savingRef = useRef(false);
+ const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update");
+ const updateProject = useAtomCommand(projectEnvironment.update, "project actions update");
+ const upsertKeybinding = useAtomCommand(
+ serverEnvironment.upsertKeybinding,
+ "action shortcut update",
+ );
+ const removeKeybinding = useAtomCommand(
+ serverEnvironment.removeKeybinding,
+ "action shortcut removal",
+ );
+
+ async function persist(
+ transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null,
+ scriptId?: string,
+ keybinding?: string | null,
+ ): Promise> {
+ if (savingRef.current || targets.length === 0) {
+ const message = "No available machine, or another action change is saving.";
+ toastManager.add({ type: "error", title: "Actions not saved", description: message });
+ return AsyncResult.failure(Cause.fail(new Error(message)));
+ }
+ savingRef.current = true;
+ setSaving(true);
+ try {
+ for (const { environmentId, settings, keybindings, capabilities, project } of targets) {
+ const current = project
+ ? resolveProjectScripts(settings, project)
+ : settings.defaultProjectScripts;
+ const nextScripts = transform(current);
+ const effectiveScripts = nextScripts ?? settings.defaultProjectScripts;
+ const legacyWrite = resolveProjectScriptsWrite({
+ supportsProjectDefaults: capabilities.projectDefaults === true,
+ projectId: project?.id ?? null,
+ nextScripts,
+ });
+ if (capabilities.projectSettingsOverrides !== true && legacyWrite.kind === "unsupported") {
+ return reportScriptFailure(
+ AsyncResult.failure(
+ Cause.fail(new Error("Update this environment to edit its default actions.")),
+ ),
+ );
+ }
+ const result =
+ capabilities.projectSettingsOverrides !== true && legacyWrite.kind === "project"
+ ? await updateProject({
+ environmentId,
+ input: { projectId: legacyWrite.projectId, scripts: legacyWrite.scripts },
+ })
+ : await updateSettings({
+ environmentId,
+ input: {
+ patch:
+ capabilities.projectSettingsOverrides !== true &&
+ legacyWrite.kind === "settings"
+ ? legacyWrite.patch
+ : project
+ ? {
+ projectSettingsOverrides: {
+ [project.id]:
+ nextScripts === null
+ ? clearProjectSettingsOverrides(settings, project.id, [
+ "defaultProjectScripts",
+ ])
+ : {
+ ...settings.projectSettingsOverrides[project.id],
+ defaultProjectScripts: nextScripts,
+ },
+ },
+ }
+ : { defaultProjectScripts: nextScripts ?? [] },
+ },
+ });
+ if (result._tag === "Failure") return reportScriptFailure(result);
+ if (!isElectron) continue;
+ const changedIds = scriptId
+ ? [scriptId]
+ : current
+ .filter((script) => !effectiveScripts.some((next) => next.id === script.id))
+ .map((script) => script.id);
+ for (const id of changedIds) {
+ const command = commandForProjectScript(id);
+ const previousValue = keybindingValueForCommand(keybindings, command);
+ const previous = previousValue
+ ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command })
+ : null;
+ const next = decodeProjectScriptKeybindingRule({ keybinding, command });
+ const retainedElsewhere =
+ !nextScripts?.some((script) => script.id === id) &&
+ ((project && settings.defaultProjectScripts.some((script) => script.id === id)) ||
+ Object.entries(settings.projectSettingsOverrides).some(
+ ([projectId, entry]) =>
+ projectId !== project?.id &&
+ entry.defaultProjectScripts?.some((script) => script.id === id),
+ ) ||
+ projects.some(
+ (other) =>
+ other.environmentId === environmentId &&
+ other.id !== project?.id &&
+ (project ? resolveProjectScripts(settings, other) : other.scripts).some(
+ (script) => script.id === id,
+ ),
+ ));
+ const bindingResult = next
+ ? await upsertKeybinding({
+ environmentId,
+ input:
+ previous && previous.key !== next.key ? { ...next, replace: previous } : next,
+ })
+ : previous && !retainedElsewhere
+ ? await removeKeybinding({ environmentId, input: previous })
+ : null;
+ if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult);
+ }
+ }
+ return AsyncResult.success(undefined);
+ } finally {
+ savingRef.current = false;
+ setSaving(false);
+ }
+ }
+
+ function submit(scriptId: string | null, input: NewProjectScriptInput) {
+ const existingIds = [
+ ...projects.flatMap((project) => project.scripts.map((script) => script.id)),
+ ...targets.flatMap(({ settings, project }) =>
+ [
+ ...settings.defaultProjectScripts,
+ ...Object.values(settings.projectSettingsOverrides).flatMap(
+ (entry) => entry.defaultProjectScripts ?? [],
+ ),
+ ...(project?.scripts ?? []),
+ ].map((script) => script.id),
+ ),
+ ];
+ const id = scriptId ?? nextProjectScriptId(input.name, existingIds);
+ const next = buildProjectScript(id, input);
+ return persist(
+ (current) => {
+ const updated = current.map((script) =>
+ script.id === id
+ ? next
+ : input.runOnWorktreeCreate
+ ? { ...script, runOnWorktreeCreate: false }
+ : script,
+ );
+ return scriptId === null ? [...updated, next] : updated;
+ },
+ id,
+ input.keybinding,
+ );
+ }
+
+ return { saving, persist, submit };
+}
diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts
new file mode 100644
index 000000000..a851a1876
--- /dev/null
+++ b/apps/web/src/components/settings/useScopedModelAvailability.ts
@@ -0,0 +1,55 @@
+import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts";
+import { useCallback } from "react";
+
+import { getCustomModelOptionsByInstance } from "../../modelSelection";
+import {
+ applyProviderInstanceSettings,
+ deriveProviderInstanceEntries,
+ type ProviderInstanceEntry,
+} from "../../providerInstances";
+import { useEnvironments } from "../../state/environments";
+import { useSettingsScope } from "./SettingsScopeContext";
+
+/**
+ * A model choice fans out to every selected target, so it must exist on all
+ * of them. Returns the reason a (instance, model) pair cannot be applied, or
+ * null when every target can honor it. The representative's entries decide
+ * which driver the instance id names.
+ */
+export function useScopedModelDisabledReason(
+ settings: UnifiedSettings,
+ entries: readonly ProviderInstanceEntry[],
+) {
+ const { targets } = useSettingsScope();
+ const { environments } = useEnvironments();
+ return useCallback(
+ (instanceId: ProviderInstanceId, model: string): string | null => {
+ const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
+ for (const candidate of targets) {
+ const environment = environments.find(
+ (entry) => entry.environmentId === candidate.environmentId,
+ );
+ const config = environment?.serverConfig;
+ if (!config) continue;
+ const entry = applyProviderInstanceSettings(
+ deriveProviderInstanceEntries(config.providers),
+ candidate.settings,
+ ).find((option) => option.instanceId === instanceId);
+ const options = getCustomModelOptionsByInstance(
+ { ...settings, ...candidate.settings },
+ config.providers,
+ ).get(instanceId);
+ if (
+ !entry?.enabled ||
+ !entry.isAvailable ||
+ entry.driverKind !== sourceEntry?.driverKind ||
+ !options?.some((option) => option.slug === model && !option.isUnavailable)
+ ) {
+ return `This model is unavailable on ${environment?.label ?? "a selected environment"}. Select that environment to choose its model separately.`;
+ }
+ }
+ return null;
+ },
+ [entries, environments, settings, targets],
+ );
+}
diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts
new file mode 100644
index 000000000..ddf6b60b9
--- /dev/null
+++ b/apps/web/src/components/settings/useScopedSettings.ts
@@ -0,0 +1,121 @@
+import {
+ DEFAULT_SERVER_SETTINGS,
+ type ProjectScopedServerSettingKey,
+ type ServerSettings,
+ type UnifiedSettings,
+} from "@t3tools/contracts";
+import { useCallback, useMemo } from "react";
+
+import {
+ mergeEnvironmentSettings,
+ persistClientSettingsPatch,
+ useClientSettings,
+} from "../../hooks/useSettings";
+import { serverEnvironment } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { toastManager } from "../ui/toast";
+import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext";
+import {
+ persistScopedSettingsPatch,
+ planProjectOverridesClear,
+ planScopedSettingsClear,
+ planScopedSettingsPatch,
+ scopedSettingsAreMixed,
+ scopedSettingsSource,
+ type ProjectOverrideEntry,
+ type ScopedSettingsPatch,
+} from "./scopedSettings";
+
+/** Effective settings for the representative target: project overrides applied on top of its environment. */
+export function useScopedSettings(
+ selector?: (settings: UnifiedSettings) => T,
+): T {
+ const { target } = useSettingsScope();
+ const clientSettings = useClientSettings();
+ const serverSettings = target?.settings ?? DEFAULT_SERVER_SETTINGS;
+ const settings = useMemo(
+ () => mergeEnvironmentSettings(serverSettings, clientSettings),
+ [clientSettings, serverSettings],
+ );
+ return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
+}
+
+export function useScopedSettingsMixed(keys: readonly (keyof ServerSettings)[]): boolean {
+ const { targets } = useSettingsScope();
+ return scopedSettingsAreMixed(targets, keys);
+}
+
+/** Where the keys' effective values come from across the selected targets. */
+export function useScopedSettingSource(keys: readonly (keyof ServerSettings)[]) {
+ const { targets } = useSettingsScope();
+ return scopedSettingsSource(targets, keys);
+}
+
+function useRunScopedPlan() {
+ const persistServer = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
+ return useCallback(
+ (plan: ReturnType) => {
+ if (plan.unavailableReason) {
+ toastManager.add({
+ type: "warning",
+ title: "Setting not saved",
+ description: plan.unavailableReason,
+ });
+ return;
+ }
+ void persistScopedSettingsPatch(plan, persistServer, persistClientSettingsPatch).then(
+ ({ failedEnvironments, savedEnvironmentCount }) => {
+ if (failedEnvironments.length === 0) return;
+ toastManager.add({
+ type: "error",
+ title:
+ savedEnvironmentCount > 0
+ ? "Setting saved on some environments"
+ : "Setting not saved",
+ description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`,
+ });
+ },
+ );
+ },
+ [persistServer],
+ );
+}
+
+export function useUpdateScopedSettings() {
+ const { scope, environments } = useSettingsScope();
+ const run = useRunScopedPlan();
+ return useCallback(
+ (patch: ScopedSettingsPatch) => run(planScopedSettingsPatch(scope, environments, patch)),
+ [environments, run, scope],
+ );
+}
+
+/**
+ * Drop the project overrides for `keys` so the selected checkouts inherit
+ * again. Rows also render outside the settings layout (provider cards,
+ * dialogs), where there is no scope and nothing to clear.
+ */
+export function useClearScopedSettings() {
+ const context = useOptionalSettingsScope();
+ const run = useRunScopedPlan();
+ return useCallback(
+ (keys: readonly ProjectScopedServerSettingKey[]) => {
+ if (context === null) return;
+ run(planScopedSettingsClear(context.scope, context.environments, keys));
+ },
+ [context, run],
+ );
+}
+
+/** Clear `keys` on specific project entries, from an environment scope's chain popover. */
+export function useClearProjectOverrides() {
+ const context = useOptionalSettingsScope();
+ const run = useRunScopedPlan();
+ return useCallback(
+ (entries: readonly ProjectOverrideEntry[], keys: readonly ProjectScopedServerSettingKey[]) => {
+ if (context === null) return;
+ run(planProjectOverridesClear(context.environments, entries, keys));
+ },
+ [context, run],
+ );
+}
diff --git a/apps/web/src/components/settings/useSettingsProjectGroups.ts b/apps/web/src/components/settings/useSettingsProjectGroups.ts
new file mode 100644
index 000000000..8eb251499
--- /dev/null
+++ b/apps/web/src/components/settings/useSettingsProjectGroups.ts
@@ -0,0 +1,24 @@
+import { useMemo } from "react";
+
+import { useClientSettings } from "../../hooks/useSettings";
+import { selectProjectGroupingSettings } from "../../logicalProject";
+import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping";
+import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
+import { useProjects } from "../../state/entities";
+
+/** Settings uses the same logical projects as the sidebar, sorted by display name. */
+export function useSettingsProjectGroups() {
+ const projects = useProjects();
+ const settings = useClientSettings(selectProjectGroupingSettings);
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const { environments } = useEnvironments();
+ return useMemo(() => {
+ const labels = new Map(environments.map((entry) => [entry.environmentId, entry.label]));
+ return buildSidebarProjectSnapshots({
+ projects,
+ settings,
+ primaryEnvironmentId,
+ resolveEnvironmentLabel: (id) => labels.get(id) ?? null,
+ }).sort((a, b) => a.displayName.localeCompare(b.displayName));
+ }, [environments, primaryEnvironmentId, projects, settings]);
+}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 4657a0b9a..b2bbf6aec 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -33,6 +33,8 @@ const buttonVariants = cva(
micro:
"h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-[11px] before:rounded-[calc(var(--radius-sm)-1px)] sm:text-[11px] [&_svg:not([class*='size-'])]:size-3 sm:[&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7",
+ "sm-multiline":
+ "min-h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)] whitespace-normal sm:min-h-7",
xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5",
},
diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx
index 267b314ca..33114c551 100644
--- a/apps/web/src/components/ui/switch.tsx
+++ b/apps/web/src/components/ui/switch.tsx
@@ -4,15 +4,21 @@ import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "~/lib/utils";
+/**
+ * `mixed` renders the thumb centred on a muted track for a selection whose
+ * targets disagree (the macOS mixed-state convention). It is presentational:
+ * the caller still decides what a click sets, usually on for everyone.
+ */
function Switch({
className,
size = "default",
+ mixed = false,
...props
-}: SwitchPrimitive.Root.Props & { size?: "default" | "sm" }) {
+}: SwitchPrimitive.Root.Props & { size?: "default" | "sm"; mixed?: boolean }) {
return (
diff --git a/apps/web/src/components/usage/usagePagePreferences.test.ts b/apps/web/src/components/usage/usagePagePreferences.test.ts
index dcdde98fa..f5686de01 100644
--- a/apps/web/src/components/usage/usagePagePreferences.test.ts
+++ b/apps/web/src/components/usage/usagePagePreferences.test.ts
@@ -25,7 +25,7 @@ afterEach(() => {
describe("Usage page preferences", () => {
it("uses defaults when no preference has been saved", () => {
- expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
+ expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 });
});
it.each([1, 7, 30, 90] as const)("round-trips every metric with a %i-day range", (windowDays) => {
@@ -41,7 +41,7 @@ describe("Usage page preferences", () => {
'{"metric":"cost","windowDays":365}',
])("replaces invalid preferences on the next save: %s", (value) => {
values.set(key, value);
- expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
+ expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 });
saveUsagePagePreferences({ metric: "tokens", windowDays: 7 });
expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 7 });
});
@@ -64,7 +64,7 @@ describe("Usage page preferences", () => {
throw new Error("SecurityError");
},
});
- expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
+ expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 });
expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow();
});
});
diff --git a/apps/web/src/components/usage/usagePagePreferences.ts b/apps/web/src/components/usage/usagePagePreferences.ts
index 551406147..55845be4f 100644
--- a/apps/web/src/components/usage/usagePagePreferences.ts
+++ b/apps/web/src/components/usage/usagePagePreferences.ts
@@ -10,13 +10,18 @@ const preferencesCodec = Schema.fromJsonString(UsagePagePreferencesSchema);
const decodePreferences = Schema.decodeSync(preferencesCodec);
const encodePreferences = Schema.encodeSync(preferencesCodec);
+// Limits is what most people open the page for (how much subscription quota is
+// left, and when it resets), so it is the first-visit default; the last picked
+// tab sticks after that.
+const DEFAULT_PREFERENCES: UsagePagePreferences = { metric: "limits", windowDays: 30 };
+
export function readUsagePagePreferences(): UsagePagePreferences {
try {
const stored = typeof window === "undefined" ? null : window.localStorage.getItem(STORAGE_KEY);
- return stored === null ? { metric: "cost", windowDays: 30 } : decodePreferences(stored);
+ return stored === null ? DEFAULT_PREFERENCES : decodePreferences(stored);
} catch (error) {
console.error("Could not read Usage page preferences.", error);
- return { metric: "cost", windowDays: 30 };
+ return DEFAULT_PREFERENCES;
}
}
diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts
index 9bd7a3e92..efb6d8412 100644
--- a/apps/web/src/environmentGrouping.test.ts
+++ b/apps/web/src/environmentGrouping.test.ts
@@ -12,6 +12,7 @@ import {
buildPhysicalToLogicalProjectKeyMap,
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
+ projectGroupsSpanEnvironments,
} from "./sidebarProjectGrouping";
import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
@@ -81,6 +82,40 @@ describe("environment grouping", () => {
expect(projectGroupCount).toBe(1);
});
+ it("reports whether the project groups span more than one environment", () => {
+ const grouped = makeProject({ repositoryIdentity });
+ const groupedRemote = makeProject({
+ id: ProjectId.make("project-remote"),
+ environmentId: remoteEnvironmentId,
+ repositoryIdentity,
+ });
+ const separateLocal = makeProject({
+ id: ProjectId.make("workbench-local"),
+ title: "workbench",
+ workspaceRoot: "/tmp/workbench",
+ });
+ const separateRemote = makeProject({
+ id: ProjectId.make("workbench-remote"),
+ environmentId: remoteEnvironmentId,
+ title: "workbench",
+ workspaceRoot: "/tmp/workbench",
+ });
+ const build = (projects: Project[]) =>
+ buildSidebarProjectSnapshots({
+ projects,
+ settings: defaultGroupingSettings,
+ primaryEnvironmentId,
+ resolveEnvironmentLabel: (environmentId) =>
+ environmentId === remoteEnvironmentId ? "Mac mini" : "Primary",
+ });
+
+ const groups = build([groupedRemote, grouped, separateLocal, separateRemote]);
+ expect(groups).toHaveLength(3);
+ expect(projectGroupsSpanEnvironments(groups)).toBe(true);
+ expect(projectGroupsSpanEnvironments(build([grouped, separateLocal]))).toBe(false);
+ expect(projectGroupsSpanEnvironments(build([separateRemote]))).toBe(false);
+ });
+
it("keeps projects without repository identity physically scoped", () => {
const primary = makeProject();
const remote = makeProject({
diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts
index afd503e63..9ae3b33fb 100644
--- a/apps/web/src/hooks/useHandleNewThread.test.ts
+++ b/apps/web/src/hooks/useHandleNewThread.test.ts
@@ -1,8 +1,15 @@
import { describe, expect, it, vi } from "vite-plus/test";
+import type { RuntimeMode } from "@t3tools/contracts";
const testState = vi.hoisted(() => {
let completeProjectFileRead: (value: null) => void = () => undefined;
let projectFileRead = Promise.resolve(null);
+ let targetSettings = {
+ defaultThreadEnvMode: "local" as "local" | "worktree",
+ newWorktreesStartFromOrigin: false,
+ defaultModelSelection: null,
+ defaultRuntimeMode: "full-access" as RuntimeMode,
+ };
let storedDraft: {
readonly draftId: string;
readonly environmentId: string;
@@ -35,10 +42,26 @@ const testState = vi.hoisted(() => {
get projectFileRead() {
return projectFileRead;
},
- reset(nextStoredDraft: typeof storedDraft) {
+ get targetSettings() {
+ return targetSettings;
+ },
+ reset(
+ nextStoredDraft: typeof storedDraft,
+ workspaceDefaults = {
+ envMode: "local" as "local" | "worktree",
+ startFromOrigin: false,
+ },
+ ) {
storedDraft = nextStoredDraft;
+ targetSettings = {
+ defaultThreadEnvMode: workspaceDefaults.envMode,
+ newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,
+ defaultModelSelection: null,
+ defaultRuntimeMode: "full-access",
+ };
router.state.location.href = "/";
router.navigate.mockClear();
+ draftStore.setDraftThreadContext.mockClear();
draftStore.setLogicalProjectDraftThreadId.mockClear();
projectFileRead = new Promise((resolve) => {
completeProjectFileRead = resolve;
@@ -51,18 +74,18 @@ const testState = vi.hoisted(() => {
vi.mock("@effect/atom-react", () => ({
useAtomValue: (atom: unknown) =>
atom === "primary-settings"
- ? { newWorktreesStartFromOrigin: false }
+ ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin }
: new Map([
[
- "environment-ssh",
+ "environment-primary",
{
settings: {
- defaultThreadEnvMode: "local",
- newWorktreesStartFromOrigin: false,
- defaultModelSelection: null,
+ ...testState.targetSettings,
+ newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin,
},
},
],
+ ["environment-ssh", { settings: testState.targetSettings }],
]),
}));
vi.mock("@t3tools/client-runtime/environment", () => ({
@@ -74,6 +97,17 @@ vi.mock("@t3tools/contracts", () => ({
DEFAULT_RUNTIME_MODE: "default",
DEFAULT_SERVER_SETTINGS: {},
}));
+vi.mock("@t3tools/shared/projectSettings", () => ({
+ projectDefaultModelPreference: (resolved: { settings: { defaultModelSelection: null } }) =>
+ resolved.settings.defaultModelSelection,
+ // Environment settings pass through; the tests set project fields on the
+ // project record, which the hook still honors until the server folds them.
+ resolveProjectSettings: (settings: Record) => ({
+ settings,
+ sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },
+ overrides: {},
+ }),
+}));
vi.mock("@t3tools/shared/threadEnvMode", () => ({
resolveDefaultThreadEnvMode: (input: {
readonly projectFile: "local" | "worktree" | null;
@@ -99,9 +133,9 @@ vi.mock("../composerDraftStore", () => {
useComposerDraftStore,
};
});
-vi.mock("../lib/chatThreadActions", () => ({
+vi.mock("../lib/chatThreadActions", async (importOriginal) => ({
+ ...(await importOriginal()),
hasExplicitComposerModelSelection: () => false,
- resolveNewDraftStartFromOrigin: () => false,
resolveNewThreadModelSelectionOverride: () => null,
}));
vi.mock("../lib/t3ProjectFileDefaults", () => ({
@@ -143,19 +177,41 @@ vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) }));
import { useNewThreadHandler } from "./useHandleNewThread";
-describe("useNewThreadHandler", () => {
- it.each([
- ["new", null],
- [
- "reusable",
- {
- draftId: "draft-existing",
+describe.each([
+ ["new", null],
+ [
+ "reusable",
+ {
+ draftId: "draft-existing",
+ environmentId: "environment-ssh",
+ promotedTo: null,
+ threadId: "thread-existing",
+ },
+ ],
+])("useNewThreadHandler with a %s draft", (_, draft) => {
+ it.each(["approval-required", "auto-accept-edits", "auto", "full-access"] as const)(
+ "uses the target environment's %s permissions for new threads",
+ async (runtimeMode) => {
+ testState.reset(draft);
+ testState.targetSettings.defaultRuntimeMode = runtimeMode;
+ const projectRef = {
environmentId: "environment-ssh",
- promotedTo: null,
- threadId: "thread-existing",
- },
- ],
- ])("abandons a delayed %s draft open when the user navigates elsewhere", async (_, draft) => {
+ projectId: "project-remote",
+ } as never;
+ const pendingOpen = useNewThreadHandler()(projectRef);
+ testState.completeProjectFileRead(null);
+ const opened = await pendingOpen;
+
+ expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
+ "remote-project",
+ projectRef,
+ opened!.draftId,
+ expect.objectContaining({ runtimeMode }),
+ );
+ },
+ );
+
+ it("abandons a delayed draft open when the user navigates elsewhere", async () => {
testState.reset(draft);
const openThread = useNewThreadHandler();
const pendingOpen = openThread(
@@ -171,4 +227,58 @@ describe("useNewThreadHandler", () => {
expect(testState.router.navigate).not.toHaveBeenCalled();
expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
});
+
+ it.each([true, false])(
+ "uses the target environment's start-from-origin default of %s",
+ async (startFromOrigin) => {
+ testState.reset(draft, { envMode: "worktree", startFromOrigin });
+ const openThread = useNewThreadHandler();
+ const projectRef = {
+ environmentId: "environment-ssh",
+ projectId: "project-remote",
+ } as never;
+ const pendingOpen = openThread(projectRef);
+
+ testState.completeProjectFileRead(null);
+ const opened = await pendingOpen;
+
+ expect(opened).toEqual({
+ draftId: draft?.draftId ?? "draft-delayed",
+ threadId: draft?.threadId ?? "thread-delayed",
+ });
+ expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
+ "remote-project",
+ projectRef,
+ opened!.draftId,
+ expect.objectContaining({ envMode: "worktree", startFromOrigin }),
+ );
+ if (draft) {
+ expect(testState.draftStore.setDraftThreadContext).toHaveBeenCalledWith(
+ draft.draftId,
+ expect.objectContaining({ envMode: "worktree", startFromOrigin }),
+ );
+ }
+ },
+ );
+
+ it.each([true, false])(
+ "preserves an explicit start-from-origin choice of %s",
+ async (startFromOrigin) => {
+ testState.reset(draft, { envMode: "worktree", startFromOrigin: !startFromOrigin });
+ const openThread = useNewThreadHandler();
+ const projectRef = {
+ environmentId: "environment-ssh",
+ projectId: "project-remote",
+ } as never;
+
+ const opened = await openThread(projectRef, { envMode: "worktree", startFromOrigin });
+
+ expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
+ "remote-project",
+ projectRef,
+ opened!.draftId,
+ expect.objectContaining({ envMode: "worktree", startFromOrigin }),
+ );
+ },
+ );
});
diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts
index 9b2afb3a6..4e6d66cfd 100644
--- a/apps/web/src/hooks/useHandleNewThread.ts
+++ b/apps/web/src/hooks/useHandleNewThread.ts
@@ -4,12 +4,7 @@ import {
scopeProjectRef,
scopeThreadRef,
} from "@t3tools/client-runtime/environment";
-import {
- DEFAULT_RUNTIME_MODE,
- DEFAULT_SERVER_SETTINGS,
- type ScopedProjectRef,
- type ThreadId,
-} from "@t3tools/contracts";
+import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";
import { useParams, useRouter } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
import {
@@ -27,6 +22,10 @@ import {
getProjectOrderKey,
selectProjectGroupingSettings,
} from "../logicalProject";
+import {
+ projectDefaultModelPreference,
+ resolveProjectSettings,
+} from "@t3tools/shared/projectSettings";
import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
import {
@@ -35,7 +34,7 @@ import {
resolveNewThreadModelSelectionOverride,
} from "../lib/chatThreadActions";
import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
-import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server";
+import { environmentServerConfigsAtom } from "../state/server";
import { resolveThreadRouteTarget } from "../threadRoutes";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
import { useClientSettings } from "./useSettings";
@@ -61,7 +60,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
export function useNewThreadHandler() {
const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom);
- const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const router = useRouter();
const getCurrentRouteTarget = useCallback(() => {
@@ -100,8 +98,8 @@ export function useNewThreadHandler() {
const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref;
const currentRouteTarget = getCurrentRouteTarget();
// A new thread carries the user's working mode from the thread being
- // viewed. The target project's configured model still wins; runtime and
- // interaction modes carry independently. Branch, worktree, and env mode
+ // viewed. The target project's configured model still wins; interaction
+ // mode carries independently. Permissions, branch, worktree, and env mode
// come from configured defaults unless the caller passes them explicitly.
const carrySourceShell =
currentRouteTarget?.kind === "server"
@@ -124,11 +122,6 @@ export function useNewThreadHandler() {
: null;
const carryModelSelection =
composerModelSelection ?? carrySourceShell?.modelSelection ?? null;
- const carryRuntimeMode =
- carrySourceComposer?.runtimeMode ??
- carrySourceShell?.runtimeMode ??
- carrySourceDraft?.runtimeMode ??
- null;
const carryInteractionMode =
carrySourceComposer?.interactionMode ??
carrySourceShell?.interactionMode ??
@@ -139,10 +132,22 @@ export function useNewThreadHandler() {
candidate.id === projectRef.projectId &&
candidate.environmentId === projectRef.environmentId,
);
+ // The resolver applies project overrides and, until the server has
+ // folded them, the aggregate's own legacy fields.
+ const projectSettings = resolveProjectSettings(
+ targetServerSettings,
+ project?.id ?? null,
+ project,
+ );
+ const projectDefaultModelSelection = projectDefaultModelPreference(projectSettings);
+ const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode;
+ const projectThreadEnvMode =
+ projectSettings.sources.defaultThreadEnvMode === "project"
+ ? projectSettings.settings.defaultThreadEnvMode
+ : undefined;
const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
resolveNewThreadModelSelectionOverride({
- projectDefaultSelection:
- project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null,
+ projectDefaultSelection: projectDefaultModelSelection ?? null,
carrySelection: carryModelSelection,
carrySourceDraftId:
currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
@@ -152,16 +157,16 @@ export function useNewThreadHandler() {
// skipped entirely when a higher-priority source decides, and its
// query atom caches per project after the first call.
const resolveDefaultEnvMode = async (): Promise => {
- const consultProjectFile = project !== undefined && project.defaultThreadEnvMode == null;
+ const consultProjectFile = project !== undefined && projectThreadEnvMode == null;
return resolveDefaultThreadEnvMode({
- projectSetting: project?.defaultThreadEnvMode,
+ projectSetting: projectThreadEnvMode,
projectFile: consultProjectFile
? await readT3ProjectFileDefaultThreadEnvMode(
project.environmentId,
project.workspaceRoot,
)
: null,
- globalDefault: targetServerSettings.defaultThreadEnvMode,
+ globalDefault: projectSettings.settings.defaultThreadEnvMode,
});
};
const logicalProjectKey = project
@@ -258,14 +263,14 @@ export function useNewThreadHandler() {
envMode: defaultEnvMode,
startFromOrigin: resolveNewDraftStartFromOrigin({
envMode: defaultEnvMode,
- newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
+ newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
}),
};
}
if (workspaceContext) {
setDraftThreadContext(emptyStoredDraftThread.draftId, {
...workspaceContext,
- ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
+ ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
});
}
@@ -302,7 +307,7 @@ export function useNewThreadHandler() {
{
threadId: emptyStoredDraftThread.threadId,
...workspaceContext,
- ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
+ ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
},
);
@@ -413,9 +418,9 @@ export function useNewThreadHandler() {
options?.startFromOrigin ??
resolveNewDraftStartFromOrigin({
envMode: initialEnvMode,
- newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
+ newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
}),
- runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,
+ runtimeMode: defaultRuntimeMode,
...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
});
applyStickyState(draftId);
@@ -433,13 +438,7 @@ export function useNewThreadHandler() {
return { draftId, threadId };
})();
},
- [
- environmentServerConfigs,
- getCurrentRouteTarget,
- primaryServerSettings.newWorktreesStartFromOrigin,
- projectGroupingSettings,
- router,
- ],
+ [environmentServerConfigs, getCurrentRouteTarget, projectGroupingSettings, router],
);
}
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index 38c3de1ff..9c98d613a 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -28,8 +28,6 @@ import {
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import {
filterSharedServerPatch,
- findSharedSettingsMismatches,
- pickSharedServerSettings,
splitSharedServerPatch,
supportsSharedSettingsSync,
} from "@t3tools/client-runtime/state/shared-settings";
@@ -522,73 +520,6 @@ function useUpdateSettingsTarget(
return updateSettings;
}
-/**
- * Connected environments whose shared settings differ from the primary's,
- * plus an action that writes the primary's values to all of them. Drift
- * happens when an environment was offline during an edit or was changed by
- * an older client.
- */
-export function useSharedSettingsSync() {
- const primaryEnvironment = usePrimaryEnvironment();
- const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
- const primaryCapabilities = primaryEnvironment?.serverConfig?.environment.capabilities;
- // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls
- // back to defaults while the primary is disconnected, and "apply to all"
- // must never push defaults over real values. Same for a primary too old to
- // hold the shared keys: its decoded defaults are not a source of truth.
- const primarySettings =
- primaryEnvironment !== null && supportsSharedSettingsSync(primaryEnvironment)
- ? (primaryEnvironment.serverConfig?.settings ?? null)
- : null;
- const { environments } = useEnvironments();
- const persistServerSettings = useAtomCommand(
- serverEnvironment.updateSettings,
- "server settings update",
- );
-
- const mismatches = useMemo(
- () =>
- findSharedSettingsMismatches({
- primaryEnvironmentId,
- primarySettings,
- primaryCapabilities,
- environments: environments.map((environment) => ({
- environmentId: environment.environmentId,
- label: environment.label,
- syncEligible: supportsSharedSettingsSync(environment),
- settings: environment.serverConfig?.settings ?? null,
- capabilities: environment.serverConfig?.environment.capabilities,
- })),
- }),
- [environments, primaryEnvironmentId, primarySettings, primaryCapabilities],
- );
-
- const applyToAll = useCallback(() => {
- if (primarySettings === null) {
- return;
- }
- const patch = pickSharedServerSettings(primarySettings, primaryCapabilities);
- for (const mismatch of mismatches) {
- const target = environments.find(
- (candidate) => candidate.environmentId === mismatch.environmentId,
- );
- void persistServerSettings({
- environmentId: mismatch.environmentId,
- input: {
- patch: filterSharedServerPatch(
- patch,
- target?.serverConfig?.environment.capabilities,
- target?.serverConfig?.settings,
- primarySettings,
- ),
- },
- });
- }
- }, [environments, mismatches, persistServerSettings, primarySettings, primaryCapabilities]);
-
- return { mismatches, applyToAll };
-}
-
export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {
const settings = useEnvironmentSettings(environmentId);
return useUpdateSettingsTarget(environmentId, settings);
diff --git a/apps/web/src/lib/resourceTelemetryState.ts b/apps/web/src/lib/resourceTelemetryState.ts
index 47ca79898..4b97b3b46 100644
--- a/apps/web/src/lib/resourceTelemetryState.ts
+++ b/apps/web/src/lib/resourceTelemetryState.ts
@@ -1,4 +1,8 @@
-import type { ResourceTelemetryHistoryInput, ResourceTelemetrySnapshot } from "@t3tools/contracts";
+import type {
+ EnvironmentId,
+ ResourceTelemetryHistoryInput,
+ ResourceTelemetrySnapshot,
+} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { useCallback } from "react";
@@ -15,9 +19,14 @@ export interface ResourceTelemetryState {
readonly retry: () => Promise;
}
-export function useResourceTelemetry(): ResourceTelemetryState {
+export function useResourceTelemetry(
+ targetEnvironmentId?: EnvironmentId | null,
+): ResourceTelemetryState {
const primaryEnvironment = usePrimaryEnvironment();
- const environmentId = primaryEnvironment?.environmentId ?? null;
+ const environmentId =
+ targetEnvironmentId === undefined
+ ? (primaryEnvironment?.environmentId ?? null)
+ : targetEnvironmentId;
const query = useEnvironmentQuery(
environmentId === null
? null
@@ -40,9 +49,15 @@ export function useResourceTelemetry(): ResourceTelemetryState {
return { ...query, retry };
}
-export function useResourceTelemetryHistory(input: ResourceTelemetryHistoryInput) {
+export function useResourceTelemetryHistory(
+ input: ResourceTelemetryHistoryInput,
+ targetEnvironmentId?: EnvironmentId | null,
+) {
const primaryEnvironment = usePrimaryEnvironment();
- const environmentId = primaryEnvironment?.environmentId ?? null;
+ const environmentId =
+ targetEnvironmentId === undefined
+ ? (primaryEnvironment?.environmentId ?? null)
+ : targetEnvironmentId;
return useEnvironmentQuery(
environmentId === null
? null
diff --git a/apps/web/src/routes/settings.integrations.tsx b/apps/web/src/routes/settings.integrations.tsx
index 3fa49ae93..641a3036c 100644
--- a/apps/web/src/routes/settings.integrations.tsx
+++ b/apps/web/src/routes/settings.integrations.tsx
@@ -2,10 +2,6 @@ import { createFileRoute } from "@tanstack/react-router";
import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings";
-function SettingsIntegrationsRoute() {
- return ;
-}
-
export const Route = createFileRoute("/settings/integrations")({
- component: SettingsIntegrationsRoute,
+ component: IntegrationsSettingsPanel,
});
diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/settings.projects.tsx
index fa79f46fb..12190b421 100644
--- a/apps/web/src/routes/settings.projects.tsx
+++ b/apps/web/src/routes/settings.projects.tsx
@@ -2,26 +2,5 @@ import { createFileRoute } from "@tanstack/react-router";
import { ProjectsSettings } from "../components/settings/ProjectsSettings";
export const Route = createFileRoute("/settings/projects")({
- validateSearch: (search: Record) => ({
- project: typeof search.project === "string" ? search.project : undefined,
- machine: typeof search.machine === "string" ? search.machine : undefined,
- }),
- component: ProjectsRoute,
+ component: ProjectsSettings,
});
-
-function ProjectsRoute() {
- const { project, machine } = Route.useSearch();
- const navigate = Route.useNavigate();
- return (
- {
- void navigate({
- search: { project: project ?? undefined, machine: machine ?? undefined },
- replace: true,
- });
- }}
- />
- );
-}
diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx
index bb85b8bb6..54eb77ea0 100644
--- a/apps/web/src/routes/settings.providers.tsx
+++ b/apps/web/src/routes/settings.providers.tsx
@@ -2,10 +2,32 @@ import { createFileRoute } from "@tanstack/react-router";
import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel";
+import { useSettingsScope } from "../components/settings/SettingsScopeContext";
+/**
+ * Providers are machine state, so the page shows one environment at a time:
+ * the chosen one, or the representative of the selection. A project crumb
+ * narrows the candidates to the environments that project is registered on.
+ */
function SettingsProvidersRoute() {
const target = Route.useSearch();
- return ;
+ const { environment, scope } = useSettingsScope();
+ if (!environment) {
+ return (
+
+ {scope.kind === "environment"
+ ? `Reconnect ${scope.label} to set up its providers.`
+ : "Connect an environment to set up its providers."}
+
+ );
+ }
+ return (
+
+ );
}
export const Route = createFileRoute("/settings/providers")({
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx
index 5e921fca5..cd6ca4546 100644
--- a/apps/web/src/routes/settings.tsx
+++ b/apps/web/src/routes/settings.tsx
@@ -1,4 +1,3 @@
-import { RotateCcwIcon } from "lucide-react";
import {
Outlet,
createFileRoute,
@@ -7,18 +6,34 @@ import {
useLocation,
useNavigate,
} from "@tanstack/react-router";
-import { useCallback, useEffect, useState } from "react";
-
+import { useCallback, useEffect, useState, type ReactNode } from "react";
+import { RotateCcwIcon } from "lucide-react";
+import { Button } from "../components/ui/button";
import { useSettingsRestore } from "../components/settings/SettingsPanels";
+
import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";
-import { Button } from "../components/ui/button";
import { SidebarInset } from "../components/ui/sidebar";
import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
import { isElectron } from "../env";
+import {
+ SettingsScopeProvider,
+ useSettingsScope,
+} from "../components/settings/SettingsScopeContext";
+import { useSettingsProjectGroups } from "../components/settings/useSettingsProjectGroups";
+import { useEnvironments } from "../state/environments";
+import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice";
+import {
+ retainSettingsScope,
+ validateSettingsRouteSearch,
+} from "../components/settings/settingsScopeNavigation";
+import {
+ getSettingsSearchTargetScope,
+ getThreadAutoSettlementSearchAvailability,
+ isSettingsSearchScopeAvailable,
+} from "../components/settings/settingsSearch";
-function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {
+function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) {
const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);
-
return (
void }) {
onClick={() => void restoreDefaults()}
>
- Restore defaults
+ Restore device defaults
);
}
+/** Pages whose every row is saved on this client; the scope selects are hidden there. */
+const DEVICE_ONLY_PATHS = new Set([
+ "/settings/appearance",
+ "/settings/snap-shot",
+ "/settings/connections",
+]);
+
+function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) {
+ const { scope, connectedEnvironments } = useSettingsScope();
+ const { environments } = useEnvironments();
+ const hash = useLocation({ select: (location) => location.hash });
+ const searchTarget = getSettingsSearchTargetScope(hash);
+ const autoSettlementAvailability = searchTarget?.requiresThreadAutoSettlement
+ ? getThreadAutoSettlementSearchAvailability(environments, scope)
+ : null;
+ if (
+ scope.kind !== "unavailable" &&
+ searchTarget &&
+ autoSettlementAvailability &&
+ !autoSettlementAvailability.isTargetAvailable
+ ) {
+ return (
+
+ {autoSettlementAvailability.eligibleEnvironmentIds.length > 0
+ ? `${searchTarget.title} requires a supporting environment. Choose one to continue.`
+ : `${searchTarget.title} requires a supporting environment. Connect or update an environment to continue.`}
+
+ );
+ }
+ if (
+ scope.kind !== "unavailable" &&
+ searchTarget &&
+ !isSettingsSearchScopeAvailable(searchTarget.scope, scope.kind)
+ ) {
+ const target =
+ searchTarget.scope === "environment" ||
+ searchTarget.scope === "project" ||
+ searchTarget.scope === "checkout"
+ ? searchTarget.scope
+ : "all";
+ return (
+
+ {`${searchTarget.title} is not available for the selected target. Choose its owning scope to continue.`}
+
+ );
+ }
+ // Device-local pages ignore the scope entirely; the project page follows
+ // remembered members while a grouping change replaces its URL key.
+ if (DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") {
+ return children;
+ }
+ if (scope.kind === "unavailable")
+ return {scope.message}
;
+ if (scope.kind === "environment" && connectedEnvironments.length === 0) {
+ return (
+
+ Reconnect {scope.label} to change its settings.
+
+ );
+ }
+ return children;
+}
+
function SettingsContentLayout() {
const location = useLocation();
const navigate = useNavigate();
const canGoBack = useCanGoBack();
+ const { search, selectScope } = useSettingsScope();
+ const groups = useSettingsProjectGroups();
+ const { environments } = useEnvironments();
const [restoreSignal, setRestoreSignal] = useState(0);
- const showRestoreDefaults = location.pathname === "/settings/general";
- const handleRestored = () => setRestoreSignal((value) => value + 1);
+ const showScope = !DEVICE_ONLY_PATHS.has(location.pathname);
const navigateBackWithinApp = useCallback(() => {
if (canGoBack) {
window.history.back();
@@ -73,17 +157,31 @@ function SettingsContentLayout() {
-
- {showRestoreDefaults ? (
-
-
+
+ {location.pathname === "/settings/general" ? (
+
+ setRestoreSignal((value) => value + 1)}
+ />
) : null}
-
@@ -91,10 +189,35 @@ function SettingsContentLayout() {
}
function SettingsRouteLayout() {
- return
;
+ const rawSearch = Route.useSearch();
+ const navigate = Route.useNavigate();
+ const pathname = useLocation({ select: (location) => location.pathname });
+ return (
+
{
+ // Send every axis so the retain middleware sees an explicit target
+ // even when the choice is "all", which is the absence of a key.
+ void navigate({
+ to: pathname,
+ search: () => ({
+ project: next.project,
+ machine: next.machine,
+ checkout: next.checkout,
+ }),
+ hash: "",
+ resetScroll: false,
+ });
+ }}
+ >
+
+
+ );
}
export const Route = createFileRoute("/settings")({
+ validateSearch: validateSettingsRouteSearch,
+ search: { middlewares: [retainSettingsScope] },
beforeLoad: async ({ context, location }) => {
if (
context.authGateState.status !== "authenticated" &&
diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts
index 3489ae57b..914b0cac8 100644
--- a/apps/web/src/sidebarProjectGrouping.ts
+++ b/apps/web/src/sidebarProjectGrouping.ts
@@ -26,6 +26,19 @@ export interface SidebarProjectSnapshot extends Project {
remoteEnvironmentLabels: readonly string[];
}
+export function projectGroupsSpanEnvironments(
+ groups: ReadonlyArray
>,
+): boolean {
+ const environmentIds = new Set();
+ for (const group of groups) {
+ for (const member of group.memberProjects) {
+ environmentIds.add(member.environmentId);
+ if (environmentIds.size > 1) return true;
+ }
+ }
+ return false;
+}
+
export interface SidebarProjectPickerEntry {
group: SidebarProjectSnapshot;
targetProject: SidebarProjectGroupMember;
diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts
index af5063d5d..84d8987f7 100644
--- a/apps/web/src/state/server.ts
+++ b/apps/web/src/state/server.ts
@@ -100,10 +100,6 @@ export const primaryServerAvailableEditorsAtom = Atom.make(
get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS,
).pipe(Atom.withLabel("web-primary-server-available-editors"));
-export const primaryServerKeybindingsConfigPathAtom = Atom.make(
- (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null,
-).pipe(Atom.withLabel("web-primary-server-keybindings-config-path"));
-
const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray = [];
/**
@@ -115,8 +111,3 @@ export const primaryServerEnvironmentThemesAtom = Atom.make(
(get): ReadonlyArray =>
get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES,
).pipe(Atom.withLabel("web-primary-server-environment-themes"));
-
-export const primaryServerObservabilityAtom = Atom.make(
- (get): ServerConfig["observability"] | null =>
- get(primaryServerConfigAtom)?.observability ?? null,
-).pipe(Atom.withLabel("web-primary-server-observability"));
diff --git a/docs/internals/overview.md b/docs/internals/overview.md
index 0ad5eeab6..01b0cce7b 100644
--- a/docs/internals/overview.md
+++ b/docs/internals/overview.md
@@ -43,6 +43,15 @@ Provider-specific behavior belongs behind an adapter. Orchestration works with n
and events, so adding a provider should not require branches throughout the domain or clients.
See [provider constraints](./providers.md).
+## Settings ownership
+
+Client preferences stay in the current client; environment defaults and project overrides stay
+on their owning server. The web and desktop settings target is URL state, resolved against current
+connections and project membership. An unavailable target must not fall back to another environment.
+**All environments** is an explicit bulk edit of connected, loaded servers, not a durable global
+default or a promise to synchronize offline or future environments. Project-group targets similarly
+select known environment-local checkouts; the group itself does not store inherited defaults.
+
## Durable intent and side effects
The event log is the source of truth for orchestration state. The
diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md
index 804e2b2c9..d075882fd 100644
--- a/docs/user/permission-modes.md
+++ b/docs/user/permission-modes.md
@@ -3,8 +3,10 @@
Permission modes control when an agent needs your approval to act. Choose a mode in the message
composer; it applies to that thread. Mobile offers the same modes.
-New threads start in **Full access** unless you choose another mode before sending. A thread created
-from another thread inherits its mode.
+Set the default for new threads in **Settings → General → New threads → Permissions**.
+Projects can override the environment default. New threads use this setting rather than the
+mode of the thread you were viewing. The initial default is **Full access**; existing threads
+and modes you choose in a draft keep their permissions.
| Mode | Behavior |
| --------------------- | ------------------------------------------------------------------------------------- |
diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md
index ebbcea4a2..570489686 100644
--- a/docs/user/project-settings.md
+++ b/docs/user/project-settings.md
@@ -1,35 +1,51 @@
-# Project settings
+# Settings and project overrides
-Open **Settings → Projects**, or open a project's settings from the sidebar project filter, a
-thread's menu, the chat header, or the command palette. The project and machine pickers start at **All projects** and
-**All machines**.
+The Settings breadcrumb ends with the environment and project a change applies to. They start
+at **All environments** and **All projects** and stay selected as you move between categories or
+search for a setting.
-## Defaults and overrides
+Preferences saved on this device, such as appearance, confirmations and browser profiles, always
+show and ignore the selection. Everything else is stored on a server. Choose one environment to
+edit its settings, or leave **All environments** to edit every connected environment at once.
+Offline environments keep their current values; this is a bulk edit, not a synced global default.
-With **All projects** selected, change the default model, workspace, automatic pull, agent browser
-access, or actions for projects that inherit those values. Select an individual project to override
-a default, and reset its row to inherit again. Changing a default keeps explicit project overrides.
-A workspace preference in `t3.json` takes precedence over machine defaults when the project has no
-workspace override of its own.
+Choose a project to override settings for it on the selected environments. A layers icon beside
+each server row's title shows where the value comes from: the built-in default, the environment,
+or a project override. Click it to see that chain on every selected environment. An override can
+be reset to inherit again. Settings that cannot be overridden by a project are shown read-only
+while a project is selected.
-Select a machine to limit edits to it. **All machines** writes defaults to connected machines;
-offline machines keep their previous values. When selected machines or checkouts disagree, the row
-says so. Browser access changes apply when an agent session next starts. A machine running an older
-Pylon server can need an update before it saves some defaults; Settings names the machines to
-update.
+When the selected environments disagree, the control shows **Mixed** in place of a value and the
+layers icon turns amber. Picking a value applies it to every selected environment.
-Project grouping has a default for this client, with individual checkout overrides. Shared actions
-apply to projects that inherit them; editing a project's actions creates an independent list for
-that checkout, and resetting it uses shared actions again. Project names, icons, removal, and
-importing actions from a checkout's `t3.json` stay specific to a project. When a project has several
-checkouts, the checkout picker chooses which one to edit.
+Changing an environment value never touches a project's own override. When projects override the
+setting you are editing, the layers icon counts them and the chain lists each one with its value:
+click a project to jump to it, or **Reset all** to make those projects follow the environment
+again.
+
+Providers and diagnostics are per machine: they show one environment at a time, the primary
+one until you pick another. Every other setting fans out to the selection.
+
+## Defaults and inheritance
+
+General contains the model and workspace for new threads. Integrations controls agent browser
+access. Source Control contains automatic pull, the default pull request merge method and text
+generation. The same rows edit environment defaults or project overrides depending on the
+project crumb.
+
+The Project category, shown while a project is selected, holds the project's name, icon, actions,
+checkouts and removal. Actions belong to a project: editing them creates the project's own list
+on each selected environment, and reset returns to the environment's shared list. A project's
+`t3.json` actions can be imported there.
+
+For workspace mode, a project's `t3.json` preference applies when the project has no override.
+Browser access changes apply when an agent session next starts.
## Project icons
-Select a project, then in **Project icon** choose an icon and color, an emoji, or an image from the
-project. **Reset** returns to automatic selection, which checks `t3.json`, common favicon and app
-icon paths, and icon links in project HTML files, then falls back to an icon chosen from the
-project name.
+Select the project and open Project to choose an icon, emoji, or image. The choice applies to
+every checkout in the project group and appears on connected clients. Choose **Automatic** to let
+Pylon detect an icon again.
Icon and image choices apply to the selected checkouts in a project group and appear on connected
clients. Every environment in the group must support saved icons before custom icons are available.
@@ -39,8 +55,8 @@ cached project images and can clear them.
## Keep the default branch current
-Turn on **Automatically pull** to keep a default-branch checkout up to date with its configured
-upstream. Set it under **All projects** to make it the default, or select a project to override it.
+In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date
+with its configured upstream. Choose an environment to set the default or a project to override it.
Pylon checks in the background and when the server starts. It only pulls when it can fast-forward
and the checkout has no changed files, untracked files, or local commits. It skips checkouts on
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index c4d83c76d..4e73bc59f 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -70,11 +70,13 @@ does not prevent inactivity settlement, and an old closed or merged pull request
work you resumed after it closed. **Settled** lists threads newest first by when their work
finished, or by when you settled them yourself.
-Change these rules in **Settings → General**. They continue to run when your apps are closed. Changes
-apply to connected environments that support shared settings; offline environments and older
-servers keep their previous values. If connected environments disagree, **Apply to all** copies your
-current settings to those named in the warning. Changing a rule does not reopen already settled
-threads, and turning both rules off stops the background checks.
+Change these rules in **Settings → General**. They continue to run when your apps
+are closed. On web and desktop, choose an environment at the top to change only
+its rules, or **All environments** to update connected environments together.
+Mixed values show where the selected environments disagree. Mobile applies these
+rules to connected environments that support shared settings. Offline environments
+and older servers keep their previous values. Changing a rule does not reopen
+already settled threads.
## Link a pull request
diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts
index 73c4aa563..194c1f5c2 100644
--- a/packages/client-runtime/src/state/sharedSettings.test.ts
+++ b/packages/client-runtime/src/state/sharedSettings.test.ts
@@ -1,6 +1,7 @@
import {
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
+ ProjectId,
ProviderDriverKind,
ProviderInstanceId,
} from "@t3tools/contracts";
@@ -43,6 +44,17 @@ describe("supportsSharedSettingsSync", () => {
});
describe("splitSharedServerPatch", () => {
+ it("keeps project overrides local: project ids belong to one environment", () => {
+ const patch = {
+ projectSettingsOverrides: { [ProjectId.make("project")]: { defaultAutoPull: true } },
+ sidebarAutoSettleOnMerge: false,
+ };
+ expect(splitSharedServerPatch(patch)).toEqual({
+ sharedPatch: { sidebarAutoSettleOnMerge: false },
+ localPatch: { projectSettingsOverrides: patch.projectSettingsOverrides },
+ });
+ });
+
it.each([
{
instanceId: ProviderInstanceId.make("codex"),
diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts
index dce83977b..1802ea1d0 100644
--- a/packages/contracts/src/environment.test.ts
+++ b/packages/contracts/src/environment.test.ts
@@ -16,6 +16,11 @@ const descriptor = {
describe("ExecutionEnvironmentDescriptor", () => {
it("requires an explicit browser profile capability under version skew", () => {
expect(decodeDescriptor(descriptor).capabilities.browserProfiles).toBeUndefined();
+ expect(decodeDescriptor(descriptor).capabilities.defaultRuntimeMode).toBeUndefined();
+ expect(
+ decodeDescriptor({ ...descriptor, capabilities: { defaultRuntimeMode: true } }).capabilities
+ .defaultRuntimeMode,
+ ).toBe(true);
expect(
decodeDescriptor({ ...descriptor, capabilities: { browserProfiles: true } }).capabilities
.browserProfiles,
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 364d8551d..2c12aa6c4 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -99,6 +99,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
/** Server persists the opt-in for continuing interrupted threads after restarts. */
threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
+ /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
+ projectSettingsOverrides: Schema.optionalKey(Schema.Boolean),
+ /** Server accepts and applies the default permission mode for new threads. */
+ defaultRuntimeMode: Schema.optionalKey(Schema.Boolean),
/** Server understands thread.snooze / thread.unsnooze commands. Same
version-skew contract as threadSettlement. */
threadSnooze: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 3961e3ed7..2bb10e901 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -150,7 +150,7 @@ const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
const PRIME_AGENT_DRIVER_KIND = ProviderDriverKind.make("primeAgent");
-export const DEFAULT_MODEL = "gpt-5.6-sol";
+export const DEFAULT_MODEL = "gpt-6-astra";
/**
* Codex default-model preference, most preferred first. The provider snapshot
@@ -158,6 +158,7 @@ export const DEFAULT_MODEL = "gpt-5.6-sol";
* default; when none are available, Codex's own `isDefault` flag wins.
*/
export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [
+ DEFAULT_MODEL,
"gpt-5.6-sol",
"gpt-5.6-terra",
];
@@ -169,7 +170,7 @@ export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low";
export const DEFAULT_MODEL_BY_PROVIDER: Partial> = {
[ProviderDriverKind.make("antigravity")]: ANTIGRAVITY_DEFAULT_MODEL,
[CODEX_DRIVER_KIND]: DEFAULT_MODEL,
- [CLAUDE_DRIVER_KIND]: "claude-sonnet-5",
+ [CLAUDE_DRIVER_KIND]: "claude-fable-5-1",
[CURSOR_DRIVER_KIND]: "auto",
// Product slug, not an ACP model id. The Grok adapter treats it as "the session's current model".
[GROK_DRIVER_KIND]: "grok-build",
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index c882cc038..69069f17f 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -24,6 +24,35 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
const encodeServerSettings = Schema.encodeSync(ServerSettings);
const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
+describe("ServerSettings default permissions", () => {
+ it("keeps full access for settings saved before a default was configured", () => {
+ expect(decodeServerSettings({}).defaultRuntimeMode).toBe("full-access");
+ expect(DEFAULT_SERVER_SETTINGS.defaultRuntimeMode).toBe("full-access");
+ });
+
+ it.each(["approval-required", "auto-accept-edits", "auto", "full-access"])(
+ "round-trips %s as an environment default and project override",
+ (defaultRuntimeMode) => {
+ const input = {
+ defaultRuntimeMode,
+ projectSettingsOverrides: { project: { defaultRuntimeMode } },
+ };
+ expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
+ expect(decodeServerSettingsPatch(input)).toEqual(input);
+ },
+ );
+
+ it("rejects unsupported permission defaults", () => {
+ expect(() => decodeServerSettings({ defaultRuntimeMode: "unsupported" })).toThrow();
+ expect(() => decodeServerSettingsPatch({ defaultRuntimeMode: "unsupported" })).toThrow();
+ expect(() =>
+ decodeServerSettingsPatch({
+ projectSettingsOverrides: { project: { defaultRuntimeMode: "unsupported" } },
+ }),
+ ).toThrow();
+ });
+});
+
describe("ServerSettings usage price overrides", () => {
const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 9bc04b94c..5cfbb1d92 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -7,6 +7,7 @@ import * as Schema from "effect/Schema";
import * as SchemaTransformation from "effect/SchemaTransformation";
import {
ForwardCompatibleNullable,
+ NonNegativeInt,
ProjectId,
TrimmedNonEmptyString,
TrimmedString,
@@ -20,7 +21,12 @@ import {
DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
ProviderOptionSelections,
} from "./model.ts";
-import { ModelSelection, ProjectScript } from "./orchestration.ts";
+import {
+ DEFAULT_RUNTIME_MODE,
+ ModelSelection,
+ ProjectScript,
+ RuntimeMode,
+} from "./orchestration.ts";
import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
import {
DEFAULT_PREVIEW_APPEARANCE,
@@ -880,7 +886,7 @@ export const AntigravitySettings = makeProviderSettingsSchema(
Schema.annotateKey({
title: "Sign-in method",
description:
- "Google account uses your Antigravity subscription. Gemini Enterprise needs a GCP project and location. API key and Agent Platform bill the credential you enter.",
+ "Google accounts use your subscription; API keys and Agent Platform bill usage.",
providerSettingsForm: {
control: "select",
options: ANTIGRAVITY_AUTH_METHODS,
@@ -892,8 +898,7 @@ export const AntigravitySettings = makeProviderSettingsSchema(
Schema.withDecodingDefault(Effect.succeed("")),
Schema.annotateKey({
title: "API key",
- description:
- "Gemini API key, or a Vertex AI express key for Agent Platform. Stored in plain text on this environment.",
+ description: "Gemini or Vertex AI express key. Stored in plain text.",
providerSettingsForm: {
control: "password",
placeholder: "Optional",
@@ -914,7 +919,7 @@ export const AntigravitySettings = makeProviderSettingsSchema(
Schema.withDecodingDefault(Effect.succeed("")),
Schema.annotateKey({
title: "GCP location",
- description: "Region for Gemini Enterprise or Agent Platform, such as us-central1.",
+ description: "Region for Gemini Enterprise or Agent Platform.",
providerSettingsForm: { placeholder: "us-central1", clearWhenEmpty: "omit" },
}),
),
@@ -922,8 +927,7 @@ export const AntigravitySettings = makeProviderSettingsSchema(
Schema.withDecodingDefault(Effect.succeed("")),
Schema.annotateKey({
title: "Binary path",
- description:
- "Optional path to the official Antigravity ACP executable. Leave empty for automatic selection.",
+ description: "Custom ACP executable. Leave empty to select automatically.",
providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" },
}),
),
@@ -1059,6 +1063,57 @@ export const BackgroundActivitySettings = Schema.Struct({
}).pipe(Schema.withDecodingDefault(Effect.succeed({})));
export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
+/**
+ * Server settings a project may override. Every other server setting is
+ * environment-wide: providers, keybindings, observability, device hosts,
+ * background activity, theme. UI, search and the write planner derive
+ * eligibility from this list, so adding a key here is the whole opt-in.
+ */
+export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
+ "defaultModelSelection",
+ "defaultRuntimeMode",
+ "defaultThreadEnvMode",
+ "newWorktreesStartFromOrigin",
+ "defaultAutoPull",
+ "defaultProjectScripts",
+ "enableAgentBrowserAccess",
+ "enableAgentDeviceAccess",
+ "textGenerationModelSelection",
+ "sourceControlWriterModelSelection",
+ "sourceControlWritingStyle",
+ "pullRequestMergeMethod",
+ "sidebarAutoSettleOnMerge",
+ "sidebarAutoSettleAfterDays",
+ "continueThreadsAfterServerUpdate",
+ "enableLegacyTokenStreaming",
+] as const;
+export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number];
+
+/**
+ * One project's overrides. An absent key inherits the environment value;
+ * `null` is a real value where the environment type is nullable (no default
+ * model, no dedicated writer model, never auto-settle).
+ */
+export const ProjectSettingsOverrides = Schema.Struct({
+ defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
+ defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
+ defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
+ newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
+ defaultAutoPull: Schema.optionalKey(Schema.Boolean),
+ defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)),
+ enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
+ enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),
+ textGenerationModelSelection: Schema.optionalKey(ModelSelection),
+ sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
+ sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings),
+ pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)),
+ sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
+ sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
+ continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
+ enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
+} satisfies Record);
+export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;
+
export const ServerSettings = Schema.Struct({
// Legacy token-by-token assistant output. Deliberately a fresh key (was
// `enableAssistantStreaming`): decoding drops the old key, so everyone,
@@ -1099,6 +1154,9 @@ export const ServerSettings = Schema.Struct({
defaultModelSelection: Schema.NullOr(ModelSelection).pipe(
Schema.withDecodingDefault(Effect.succeed(null)),
),
+ defaultRuntimeMode: RuntimeMode.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)),
+ ),
/**
* Whether agents may drive simulators and emulators. Gates the `device_*`
* MCP tools and the preconfigured `agent-device` CLI the same way
@@ -1116,6 +1174,25 @@ export const ServerSettings = Schema.Struct({
/** Whether the server-local Device panel setup flow has been completed. */
deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))),
+ /**
+ * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`.
+ * The source of truth for project settings; `projectAgentBrowserAccessOverrides`,
+ * `projectAutoPullOverrides` and `projectScriptOverrides` are derived views
+ * kept for one release so older clients keep reading them.
+ */
+ projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe(
+ Schema.withDecodingDefault(Effect.succeed({})),
+ ),
+ /**
+ * Whether the legacy per-project fields have been folded into
+ * `projectSettingsOverrides`. The fold runs once so a later reset in the
+ * settings UI is not undone by the next server start.
+ */
+ projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ /** Server-owned replay cursor; committed with canonical overrides so legacy project edits are crash-safe. */
+ projectSettingsLegacySequence: Schema.NullOr(NonNegativeInt).pipe(
+ Schema.withDecodingDefault(Effect.succeed(null)),
+ ),
sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
),
@@ -1184,6 +1261,14 @@ export const ServerSettings = Schema.Struct({
sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe(
Schema.withDecodingDefault(Effect.succeed(null)),
),
+ /**
+ * The merge method pull requests start with; `null` reuses the method
+ * last chosen on this device. Server-side so a project can override it
+ * like any other project setting.
+ */
+ pullRequestMergeMethod: Schema.NullOr(PullRequestMergeMethod).pipe(
+ Schema.withDecodingDefault(Effect.succeed(null)),
+ ),
// Legacy single-instance-per-driver settings. Continues to be the source
// of truth until `providerInstances` (below) lands per-driver migration
@@ -1280,6 +1365,7 @@ export const ServerSettingsOperation = Schema.Literals([
"check-exists",
"read-file",
"read-provider-history",
+ "read-project-settings",
"read-secret",
"remove-secret",
"remove-stale-secret",
@@ -1412,6 +1498,17 @@ export const ServerSettingsPatch = Schema.Struct({
enableDeviceSupport: Schema.optionalKey(Schema.Boolean),
deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
deviceHosts: Schema.optionalKey(SshDeviceHostConfigs),
+ defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
+ /**
+ * Per-project entry replacement: each entry replaces that project's whole
+ * override set and `null` removes it. Clearing one override means resending
+ * the entry without that key. Per-key null cannot express "clear" for the
+ * keys whose value type is itself nullable, and clients always hold the
+ * current entry from the last settings snapshot.
+ */
+ projectSettingsOverrides: Schema.optionalKey(
+ Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)),
+ ),
sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
backgroundActivity: Schema.optionalKey(
@@ -1438,6 +1535,7 @@ export const ServerSettingsPatch = Schema.Struct({
}),
),
sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
+ pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)),
observability: Schema.optionalKey(
Schema.Struct({
otlpTracesUrl: Schema.optionalKey(TrimmedString),
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 07a31768f..3dc5096d3 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -111,6 +111,10 @@
"types": "./src/projectScripts.ts",
"import": "./src/projectScripts.ts"
},
+ "./projectSettings": {
+ "types": "./src/projectSettings.ts",
+ "import": "./src/projectSettings.ts"
+ },
"./threadEnvMode": {
"types": "./src/threadEnvMode.ts",
"import": "./src/threadEnvMode.ts"
diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts
index 4d98e36b4..5cb988753 100644
--- a/packages/shared/src/projectScripts.ts
+++ b/packages/shared/src/projectScripts.ts
@@ -1,23 +1,41 @@
import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts";
-/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */
+type ProjectScriptSettings = Pick<
+ ServerSettings,
+ | "defaultProjectScripts"
+ | "projectScriptOverrides"
+ | "projectSettingsOverrides"
+ | "projectSettingsFolded"
+>;
+
+/**
+ * The project's override wins, then environment defaults. Until the legacy
+ * fields have been folded into `projectSettingsOverrides`, the old map (null
+ * there meant "reset to machine defaults") and the aggregate's own scripts
+ * still count, so a server that has not run the fold yet behaves as before.
+ */
export function resolveProjectScripts(
- settings: Pick,
+ settings: ProjectScriptSettings,
project: { id: ProjectId; scripts: readonly ProjectScript[] },
): readonly ProjectScript[] {
- const override = settings.projectScriptOverrides[project.id];
- if (override === null) return settings.defaultProjectScripts;
- return (
- override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts)
- );
+ const override = settings.projectSettingsOverrides[project.id]?.defaultProjectScripts;
+ if (override !== undefined) return override;
+ if (settings.projectSettingsFolded) return settings.defaultProjectScripts;
+ const legacy = settings.projectScriptOverrides[project.id];
+ if (legacy === null) return settings.defaultProjectScripts;
+ return legacy ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts);
}
export function projectScriptsInheritDefaults(
- settings: Pick,
+ settings: ProjectScriptSettings,
project: { id: ProjectId; scripts: readonly ProjectScript[] },
): boolean {
- const override = settings.projectScriptOverrides[project.id];
- return override === null || (override === undefined && project.scripts.length === 0);
+ if (settings.projectSettingsOverrides[project.id]?.defaultProjectScripts !== undefined) {
+ return false;
+ }
+ if (settings.projectSettingsFolded) return true;
+ const legacy = settings.projectScriptOverrides[project.id];
+ return legacy === null || (legacy === undefined && project.scripts.length === 0);
}
interface ProjectScriptRuntimeEnvInput {
diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts
new file mode 100644
index 000000000..660dc3996
--- /dev/null
+++ b/packages/shared/src/projectSettings.test.ts
@@ -0,0 +1,225 @@
+import {
+ DEFAULT_SERVER_SETTINGS,
+ PROJECT_SCOPED_SERVER_SETTING_KEYS,
+ ProjectId,
+ ProviderInstanceId,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+import { createModelSelection } from "./model.ts";
+import {
+ clearProjectSettingsOverrides,
+ hasProjectSettingsOverrides,
+ projectDefaultModelPreference,
+ resolveProjectSettings,
+ withProjectSettingsOverrides,
+} from "./projectSettings.ts";
+import { applyServerSettingsPatch } from "./serverSettings.ts";
+
+const projectId = ProjectId.make("project-a");
+const otherProjectId = ProjectId.make("project-b");
+
+describe("resolveProjectSettings", () => {
+ it("inherits every scopable key when the project has no overrides", () => {
+ const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId);
+ expect(resolved.settings).toBe(DEFAULT_SERVER_SETTINGS);
+ for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
+ expect(resolved.sources[key]).toBe("environment");
+ }
+ expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null).settings).toBe(
+ DEFAULT_SERVER_SETTINGS,
+ );
+ });
+
+ it("applies overrides per key and reports their source", () => {
+ const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ defaultAutoPull: true,
+ sidebarAutoSettleAfterDays: 3,
+ projectSettingsOverrides: {
+ [projectId]: { defaultAutoPull: false, sidebarAutoSettleAfterDays: null },
+ },
+ });
+ const resolved = resolveProjectSettings(settings, projectId);
+ expect(resolved.settings.defaultAutoPull).toBe(false);
+ expect(resolved.settings.sidebarAutoSettleAfterDays).toBeNull();
+ expect(resolved.settings.defaultThreadEnvMode).toBe(settings.defaultThreadEnvMode);
+ expect(resolved.sources.defaultAutoPull).toBe("project");
+ expect(resolved.sources.sidebarAutoSettleAfterDays).toBe("project");
+ expect(resolved.sources.defaultThreadEnvMode).toBe("environment");
+ expect(resolveProjectSettings(settings, otherProjectId).settings.defaultAutoPull).toBe(true);
+ });
+
+ it("keeps the environment text generation model when the override's provider is disabled", () => {
+ const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
+ const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ providers: { claudeAgent: { enabled: false } },
+ projectSettingsOverrides: {
+ [projectId]: { textGenerationModelSelection: disabledSelection },
+ },
+ });
+ const resolved = resolveProjectSettings(settings, projectId);
+ expect(resolved.settings.textGenerationModelSelection).toEqual(
+ settings.textGenerationModelSelection,
+ );
+ expect(resolved.sources.textGenerationModelSelection).toBe("environment");
+ });
+
+ it("preserves an unavailable project provider preference for web and mobile admission", () => {
+ const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
+ const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ providers: { claudeAgent: { enabled: false } },
+ projectSettingsOverrides: { [projectId]: { defaultModelSelection: selection } },
+ });
+ const resolved = resolveProjectSettings(settings, projectId);
+ expect(resolved.settings.defaultModelSelection).toEqual(settings.defaultModelSelection);
+ expect(projectDefaultModelPreference(resolved)).toEqual(selection);
+ const inherited = resolveProjectSettings(
+ { ...settings, projectSettingsOverrides: {} },
+ projectId,
+ );
+ expect(projectDefaultModelPreference(inherited)).toEqual(settings.defaultModelSelection);
+ });
+
+ it("honours the aggregate's own fields only until the server has folded them", () => {
+ const aggregateModel = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5");
+ const project = {
+ defaultModelSelection: aggregateModel,
+ defaultThreadEnvMode: "local" as const,
+ };
+ const unfolded = resolveProjectSettings(
+ { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: false },
+ projectId,
+ project,
+ );
+ expect(unfolded.settings.defaultModelSelection).toEqual(aggregateModel);
+ expect(unfolded.settings.defaultThreadEnvMode).toBe("local");
+ expect(unfolded.sources.defaultModelSelection).toBe("project");
+ // A stored override still beats the aggregate before the fold.
+ const overridden = resolveProjectSettings(
+ {
+ ...DEFAULT_SERVER_SETTINGS,
+ projectSettingsFolded: false,
+ projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "worktree" } },
+ },
+ projectId,
+ project,
+ );
+ expect(overridden.settings.defaultThreadEnvMode).toBe("worktree");
+ // After the fold a reset in the record wins over the stale aggregate.
+ const folded = resolveProjectSettings(
+ { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true },
+ projectId,
+ project,
+ );
+ expect(folded.settings.defaultModelSelection).toBeNull();
+ expect(folded.sources.defaultModelSelection).toBe("environment");
+ });
+
+ it("keeps the environment default model when the override's provider is disabled", () => {
+ const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
+ const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ providers: { claudeAgent: { enabled: false } },
+ projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } },
+ });
+ const resolved = resolveProjectSettings(settings, projectId);
+ expect(resolved.settings.defaultModelSelection).toBeNull();
+ expect(resolved.sources.defaultModelSelection).toBe("environment");
+ });
+});
+
+describe("projectSettingsOverrides patches", () => {
+ it("replaces a project's entry, removes it with null, and drops empty entries", () => {
+ const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ projectSettingsOverrides: {
+ [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
+ [otherProjectId]: { defaultAutoPull: false },
+ },
+ });
+ expect(hasProjectSettingsOverrides(first)).toBe(true);
+ const replaced = applyServerSettingsPatch(first, {
+ projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } },
+ });
+ expect(replaced.projectSettingsOverrides[projectId]).toEqual({
+ enableAgentBrowserAccess: false,
+ });
+ expect(replaced.projectSettingsOverrides[otherProjectId]).toEqual({ defaultAutoPull: false });
+ const emptied = applyServerSettingsPatch(replaced, {
+ projectSettingsOverrides: { [projectId]: {} },
+ });
+ expect(emptied.projectSettingsOverrides[projectId]).toBeUndefined();
+ const removed = applyServerSettingsPatch(replaced, {
+ projectSettingsOverrides: { [projectId]: null },
+ });
+ expect(removed.projectSettingsOverrides).toEqual({
+ [otherProjectId]: { defaultAutoPull: false },
+ });
+ expect(hasProjectSettingsOverrides(DEFAULT_SERVER_SETTINGS)).toBe(false);
+ });
+
+ it("derives the legacy per-key maps from the generic record", () => {
+ const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ projectSettingsOverrides: {
+ [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
+ [otherProjectId]: { defaultProjectScripts: [] },
+ },
+ });
+ expect(settings.projectAutoPullOverrides).toEqual({ [projectId]: true });
+ expect(settings.projectAgentBrowserAccessOverrides).toEqual({ [projectId]: false });
+ expect(settings.projectScriptOverrides).toEqual({ [otherProjectId]: [] });
+ });
+
+ it("translates legacy per-key patches into the generic record", () => {
+ const written = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ projectAutoPullOverrides: { [projectId]: true },
+ projectAgentBrowserAccessOverrides: { [projectId]: false, [otherProjectId]: true },
+ });
+ expect(written.projectSettingsOverrides).toEqual({
+ [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
+ [otherProjectId]: { enableAgentBrowserAccess: true },
+ });
+ const cleared = applyServerSettingsPatch(written, {
+ projectAgentBrowserAccessOverrides: { [projectId]: null, [otherProjectId]: null },
+ });
+ expect(cleared.projectSettingsOverrides).toEqual({ [projectId]: { defaultAutoPull: true } });
+ });
+
+ it("lets a canonical entry win over a legacy map for the same project", () => {
+ const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } },
+ });
+ // The canonical entry omits defaultAutoPull to clear it; the stale legacy
+ // map in the same patch must not put it back.
+ const next = applyServerSettingsPatch(current, {
+ projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "local" } },
+ projectAutoPullOverrides: { [projectId]: true, [otherProjectId]: false },
+ });
+ expect(next.projectSettingsOverrides).toEqual({
+ [projectId]: { defaultThreadEnvMode: "local" },
+ [otherProjectId]: { defaultAutoPull: false },
+ });
+ });
+
+ it("builds replacement entries and clears individual keys", () => {
+ const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ projectSettingsOverrides: {
+ [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
+ },
+ });
+ expect(clearProjectSettingsOverrides(settings, projectId, ["defaultAutoPull"])).toEqual({
+ enableAgentBrowserAccess: false,
+ });
+ expect(
+ clearProjectSettingsOverrides(settings, projectId, [
+ "defaultAutoPull",
+ "enableAgentBrowserAccess",
+ ]),
+ ).toBeNull();
+ expect(clearProjectSettingsOverrides(settings, otherProjectId, ["defaultAutoPull"])).toBeNull();
+ expect(withProjectSettingsOverrides(settings, projectId, null)).toEqual({});
+ expect(
+ withProjectSettingsOverrides(settings, otherProjectId, { defaultThreadEnvMode: "worktree" }),
+ ).toEqual({
+ ...settings.projectSettingsOverrides,
+ [otherProjectId]: { defaultThreadEnvMode: "worktree" },
+ });
+ });
+});
diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts
new file mode 100644
index 000000000..f690f62f5
--- /dev/null
+++ b/packages/shared/src/projectSettings.ts
@@ -0,0 +1,133 @@
+import {
+ type ModelSelection,
+ PROJECT_SCOPED_SERVER_SETTING_KEYS,
+ type ProjectId,
+ type ProjectScopedServerSettingKey,
+ type ProjectSettingsOverrides,
+ type ServerSettings,
+ type ThreadEnvMode,
+} from "@t3tools/contracts";
+import { isModelSelectionProviderEnabled } from "./serverSettings.ts";
+
+export type ProjectSettingSource = "environment" | "project";
+
+export type ProjectSettingSources = Readonly<
+ Record
+>;
+
+export interface ResolvedProjectSettings {
+ /** Environment settings with the project's overrides applied. */
+ readonly settings: ServerSettings;
+ /** Where each scopable key's effective value came from. */
+ readonly sources: ProjectSettingSources;
+ /** The project's raw override entry; `{}` when it has none. */
+ readonly overrides: ProjectSettingsOverrides;
+}
+
+const EMPTY_OVERRIDES: ProjectSettingsOverrides = {};
+
+/** Keep the configured provider intent available to new-thread admission checks. */
+export function projectDefaultModelPreference(
+ resolved: ResolvedProjectSettings,
+): ModelSelection | null {
+ return Object.hasOwn(resolved.overrides, "defaultModelSelection")
+ ? (resolved.overrides.defaultModelSelection ?? null)
+ : resolved.settings.defaultModelSelection;
+}
+
+const ENVIRONMENT_SOURCES: ProjectSettingSources = Object.fromEntries(
+ PROJECT_SCOPED_SERVER_SETTING_KEYS.map((key) => [key, "environment"]),
+) as Record;
+
+/** Cheap check so hot paths skip the projectId lookup when nothing is overridden. */
+export function hasProjectSettingsOverrides(
+ settings: Pick,
+): boolean {
+ for (const entry of Object.values(settings.projectSettingsOverrides)) {
+ if (Object.keys(entry).length > 0) return true;
+ }
+ return false;
+}
+
+/**
+ * The project aggregate's own model and workspace fields. They remain the
+ * source of truth until the server has folded them into the override record;
+ * after the fold the record alone decides, so a reset there cannot be undone
+ * by a stale aggregate value.
+ */
+export interface LegacyProjectSettingsFields {
+ readonly defaultModelSelection?: ModelSelection | null | undefined;
+ readonly defaultThreadEnvMode?: ThreadEnvMode | null | undefined;
+}
+
+/**
+ * Apply one project's overrides on top of environment settings. A model
+ * override whose provider is disabled on this environment falls back to the
+ * environment value, the same guard the environment-level selection gets.
+ */
+export function resolveProjectSettings(
+ settings: ServerSettings,
+ projectId: ProjectId | null,
+ project?: LegacyProjectSettingsFields,
+): ResolvedProjectSettings {
+ const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId];
+ const overrides: ProjectSettingsOverrides =
+ project === undefined || settings.projectSettingsFolded
+ ? (stored ?? EMPTY_OVERRIDES)
+ : {
+ ...(project.defaultModelSelection != null
+ ? { defaultModelSelection: project.defaultModelSelection }
+ : {}),
+ ...(project.defaultThreadEnvMode != null
+ ? { defaultThreadEnvMode: project.defaultThreadEnvMode }
+ : {}),
+ ...stored,
+ };
+ if (Object.keys(overrides).length === 0) {
+ return { settings, sources: ENVIRONMENT_SOURCES, overrides: EMPTY_OVERRIDES };
+ }
+ const sources: Record = {
+ ...ENVIRONMENT_SOURCES,
+ };
+ const effective: Record = { ...settings };
+ for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
+ if (!Object.hasOwn(overrides, key)) continue;
+ const value = overrides[key];
+ // A model on a disabled provider falls back to the environment, like the
+ // environment-level guards do for these keys.
+ if (
+ (key === "textGenerationModelSelection" || key === "defaultModelSelection") &&
+ value !== undefined &&
+ value !== null &&
+ !isModelSelectionProviderEnabled(settings, value as ModelSelection)
+ ) {
+ continue;
+ }
+ effective[key] = value;
+ sources[key] = "project";
+ }
+ return { settings: effective as ServerSettings, sources, overrides };
+}
+
+/** Replace the project's entry, dropping it entirely when nothing is overridden. */
+export function withProjectSettingsOverrides(
+ settings: Pick,
+ projectId: ProjectId,
+ next: ProjectSettingsOverrides | null,
+): ServerSettings["projectSettingsOverrides"] {
+ const { [projectId]: _removed, ...rest } = settings.projectSettingsOverrides;
+ return next === null || Object.keys(next).length === 0 ? rest : { ...rest, [projectId]: next };
+}
+
+/** The project's entry with `keys` removed; `null` when that leaves it empty. */
+export function clearProjectSettingsOverrides(
+ settings: Pick,
+ projectId: ProjectId,
+ keys: readonly ProjectScopedServerSettingKey[],
+): ProjectSettingsOverrides | null {
+ const current = settings.projectSettingsOverrides[projectId];
+ if (current === undefined) return null;
+ const next = { ...current };
+ for (const key of keys) delete next[key];
+ return Object.keys(next).length === 0 ? null : next;
+}
diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts
index cc783fe64..2658db334 100644
--- a/packages/shared/src/serverSettings.test.ts
+++ b/packages/shared/src/serverSettings.test.ts
@@ -20,6 +20,9 @@ import {
resolveProjectAutoPull,
} from "./serverSettings.ts";
+/** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */
+const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true };
+
describe("serverSettings helpers", () => {
it("replaces SSH host lists when saving, editing, and removing hosts", () => {
const host = { id: "mini", label: "Mac mini", target: "mini" };
@@ -40,14 +43,19 @@ describe("serverSettings helpers", () => {
icon: "play" as const,
runOnWorktreeCreate: false,
};
- const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] };
+ // Before the one-time fold, scripts stored on the project aggregate still apply.
+ const unfolded = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ defaultProjectScripts: [action],
+ });
+ expect(resolveProjectScripts(unfolded, existing)).toEqual(existing.scripts);
+ expect(projectScriptsInheritDefaults(unfolded, existing)).toBe(false);
+ const defaults = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, {
defaultProjectScripts: [action],
});
expect(resolveProjectScripts(defaults, project)).toEqual([action]);
expect(projectScriptsInheritDefaults(defaults, project)).toBe(true);
- const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] };
- expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts);
- expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false);
+ expect(resolveProjectScripts(defaults, existing)).toEqual([action]);
const disabled = applyServerSettingsPatch(defaults, {
projectScriptOverrides: { [project.id]: [] },
});
@@ -82,7 +90,7 @@ describe("serverSettings helpers", () => {
};
const firstAction = { ...defaultAction, command: "npm run lint" };
const secondAction = { ...defaultAction, command: "npm run build" };
- const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ const firstUpdate = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, {
defaultProjectScripts: [defaultAction],
projectScriptOverrides: { [firstProject.id]: [firstAction] },
});
diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts
index f969e4412..44bbe4ef1 100644
--- a/packages/shared/src/serverSettings.ts
+++ b/packages/shared/src/serverSettings.ts
@@ -4,6 +4,8 @@ import {
resolveProviderInstanceEnabled,
type ModelSelection,
type ProjectId,
+ type ProjectScopedServerSettingKey,
+ type ProjectSettingsOverrides,
type ProviderDriverKind,
type ServerProvider,
ServerSettings,
@@ -24,22 +26,33 @@ import {
const ServerSettingsJson = fromLenientJson(ServerSettings);
const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
+/** @deprecated Read `resolveProjectSettings(...).settings.enableAgentBrowserAccess`. */
export function resolveProjectAgentBrowserAccess(
- settings: Pick,
+ settings: Pick<
+ ServerSettings,
+ "enableAgentBrowserAccess" | "projectAgentBrowserAccessOverrides" | "projectSettingsOverrides"
+ >,
projectId: ProjectId,
): boolean {
return (
- settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess
+ settings.projectSettingsOverrides[projectId]?.enableAgentBrowserAccess ??
+ settings.projectAgentBrowserAccessOverrides[projectId] ??
+ settings.enableAgentBrowserAccess
);
}
+/** @deprecated Read `resolveProjectSettings(...).settings.defaultAutoPull`. */
export function resolveProjectAutoPull(
- settings: Pick,
+ settings: Pick<
+ ServerSettings,
+ "defaultAutoPull" | "projectAutoPullOverrides" | "projectSettingsOverrides"
+ >,
projectId: ProjectId,
legacyAutoPull: boolean | undefined,
): boolean {
// Existing opt-ins stay enabled until explicitly overridden or reset.
return (
+ settings.projectSettingsOverrides[projectId]?.defaultAutoPull ??
settings.projectAutoPullOverrides[projectId] ??
(legacyAutoPull === true || settings.defaultAutoPull)
);
@@ -160,10 +173,97 @@ function mergeSettingsEntries(
return Object.fromEntries(next);
}
+/**
+ * Derived views of `projectSettingsOverrides` for clients that still read
+ * the legacy per-key maps. Recomputed on every patch and load so they
+ * cannot drift from the generic record.
+ */
+export function deriveLegacyProjectOverrides(
+ settings: Pick,
+): Pick<
+ ServerSettings,
+ "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides" | "projectScriptOverrides"
+> {
+ const projectAgentBrowserAccessOverrides: Record = {};
+ const projectAutoPullOverrides: Record = {};
+ const projectScriptOverrides: Record = {};
+ for (const [projectId, entry] of Object.entries(settings.projectSettingsOverrides)) {
+ if (entry.enableAgentBrowserAccess !== undefined) {
+ projectAgentBrowserAccessOverrides[projectId] = entry.enableAgentBrowserAccess;
+ }
+ if (entry.defaultAutoPull !== undefined) {
+ projectAutoPullOverrides[projectId] = entry.defaultAutoPull;
+ }
+ if (entry.defaultProjectScripts !== undefined) {
+ projectScriptOverrides[projectId] = entry.defaultProjectScripts;
+ }
+ }
+ return { projectAgentBrowserAccessOverrides, projectAutoPullOverrides, projectScriptOverrides };
+}
+
+/**
+ * Rewrite a patch that still uses the legacy per-key project maps into
+ * entries of `projectSettingsOverrides`, so older clients keep editing the
+ * values the server actually reads. `null` in a legacy map clears that one
+ * override.
+ */
+function translateLegacyProjectOverridePatch(
+ current: Pick,
+ patch: ServerSettingsPatch,
+): ServerSettingsPatch {
+ const {
+ projectAgentBrowserAccessOverrides,
+ projectAutoPullOverrides,
+ projectScriptOverrides,
+ ...rest
+ } = patch;
+ if (
+ projectAgentBrowserAccessOverrides === undefined &&
+ projectAutoPullOverrides === undefined &&
+ projectScriptOverrides === undefined
+ ) {
+ return patch;
+ }
+ const currentEntries: Readonly> =
+ current.projectSettingsOverrides;
+ const entries = new Map(
+ Object.entries(rest.projectSettingsOverrides ?? {}),
+ );
+ // A canonical entry in the same patch is the newer representation; a legacy
+ // map must not resurrect a key that entry deliberately omits.
+ const canonicalProjectIds = new Set(Object.keys(rest.projectSettingsOverrides ?? {}));
+ const applyKey = (
+ map: Readonly> | undefined,
+ key: K,
+ ) => {
+ if (map === undefined) return;
+ for (const [projectId, value] of Object.entries(map)) {
+ if (canonicalProjectIds.has(projectId)) continue;
+ const entry: ProjectSettingsOverrides = {
+ ...(entries.get(projectId) ?? currentEntries[projectId]),
+ };
+ if (value === null || value === undefined) {
+ delete entry[key];
+ } else {
+ entry[key] = value;
+ }
+ entries.set(projectId, Object.keys(entry).length === 0 ? null : entry);
+ }
+ };
+ applyKey(projectAgentBrowserAccessOverrides, "enableAgentBrowserAccess");
+ applyKey(projectAutoPullOverrides, "defaultAutoPull");
+ applyKey(projectScriptOverrides, "defaultProjectScripts");
+ return {
+ ...rest,
+ projectSettingsOverrides: Object.fromEntries(entries),
+ } as ServerSettingsPatch;
+}
+
export function applyServerSettingsPatch(
current: ServerSettings,
- patch: ServerSettingsPatch,
+ rawPatch: ServerSettingsPatch,
): ServerSettings {
+ const patch = translateLegacyProjectOverridePatch(current, rawPatch);
const selectionPatch = patch.textGenerationModelSelection;
const {
automaticGitFetchInterval,
@@ -173,8 +273,13 @@ export function applyServerSettingsPatch(
// Merged per entry below; its `null` removals must not reach deepMerge.
usageLimitSources: usageLimitSourcesPatch,
usagePriceOverrides: usagePriceOverridesPatch,
- projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch,
- projectAutoPullOverrides: projectAutoPullOverridesPatch,
+ // Entry replacement: deepMerge would keep keys the client meant to clear.
+ projectSettingsOverrides: projectSettingsOverridesPatch,
+ // Already translated into `projectSettingsOverrides` above; the legacy
+ // maps are derived views and must never be merged directly.
+ projectAgentBrowserAccessOverrides: _legacyBrowserAccess,
+ projectAutoPullOverrides: _legacyAutoPull,
+ projectScriptOverrides: _legacyScripts,
...patchForMerge
} = patch;
const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
@@ -231,19 +336,12 @@ export function applyServerSettingsPatch(
...(patch.providerInstances !== undefined
? { providerInstances: patch.providerInstances }
: {}),
- ...(projectAgentBrowserAccessOverridesPatch !== undefined
+ ...(projectSettingsOverridesPatch !== undefined
? {
- projectAgentBrowserAccessOverrides: mergeSettingsEntries(
- current.projectAgentBrowserAccessOverrides,
- projectAgentBrowserAccessOverridesPatch,
- ),
- }
- : {}),
- ...(projectAutoPullOverridesPatch !== undefined
- ? {
- projectAutoPullOverrides: mergeSettingsEntries(
- current.projectAutoPullOverrides,
- projectAutoPullOverridesPatch,
+ projectSettingsOverrides: Object.fromEntries(
+ Object.entries(
+ mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch),
+ ).filter(([, entry]) => Object.keys(entry).length > 0),
),
}
: {}),
@@ -253,14 +351,6 @@ export function applyServerSettingsPatch(
...(patch.defaultProjectScripts !== undefined
? { defaultProjectScripts: patch.defaultProjectScripts }
: {}),
- ...(patch.projectScriptOverrides !== undefined
- ? {
- projectScriptOverrides: {
- ...current.projectScriptOverrides,
- ...patch.projectScriptOverrides,
- },
- }
- : {}),
...(usageLimitSourcesPatch !== undefined
? {
usageLimitSources: mergeSettingsEntries(
@@ -291,6 +381,7 @@ export function applyServerSettingsPatch(
);
const nextWithReplacements = {
...nextWithReplacementsBase,
+ ...deriveLegacyProjectOverrides(nextWithReplacementsBase),
backgroundActivity: normalizedBackgroundActivity,
automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval,
providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval,