@@ -320,10 +324,12 @@ export function SettingsRow({
export function SettingResetButton({
label,
+ tooltip = "Reset to default",
disabled = false,
onClick,
}: {
label: string;
+ tooltip?: string;
disabled?: boolean;
onClick: () => void;
}) {
@@ -345,7 +351,7 @@ export function SettingResetButton({
}
/>
-
Reset to default
+
{tooltip}
);
}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 7358ed8f9a17..f715f6ca4e6d 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -2,6 +2,7 @@ import { isElectron } from "~/env";
import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
export type SettingsPath =
+ | "/settings/projects"
| "/settings/general"
| "/settings/appearance"
| "/settings/keybindings"
@@ -49,6 +50,7 @@ export interface SettingsSearchAvailability {
export const SETTINGS_SECTION_LABELS: Readonly
> = {
"/settings/general": "General",
"/settings/appearance": "Appearance",
+ "/settings/projects": "Projects",
"/settings/keybindings": "Keybindings",
"/settings/providers": "Providers",
"/settings/integrations": "Integrations",
@@ -63,6 +65,14 @@ export const SETTINGS_SECTION_LABELS: Readonly> = {
* that may not be mounted point at their nearest stable section instead.
*/
export const SETTINGS_SEARCH_ITEMS = [
+ {
+ id: "project-defaults",
+ title: "Project defaults and overrides",
+ to: "/settings/projects",
+ searchTerms: [
+ "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts",
+ ],
+ },
{
id: "color-scheme",
title: "Color scheme",
@@ -235,14 +245,13 @@ export const SETTINGS_SEARCH_ITEMS = [
{
id: "new-threads",
title: "New threads",
- to: "/settings/general",
+ to: "/settings/projects",
searchTerms: ["default workspace mode draft local worktree"],
},
{
id: "start-from-origin",
title: "Start from origin",
to: "/settings/general",
- targetId: "new-threads",
searchTerms: ["new worktrees latest matching remote branch local"],
},
{
@@ -345,7 +354,7 @@ export const SETTINGS_SEARCH_ITEMS = [
{
id: "agent-browser-access",
title: "Agent browser access",
- to: "/settings/integrations",
+ to: "/settings/projects",
searchTerms: ["allow open drive preview tools sessions"],
},
{
diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts
index 91b757f51e0d..afd503e63c25 100644
--- a/apps/web/src/hooks/useHandleNewThread.test.ts
+++ b/apps/web/src/hooks/useHandleNewThread.test.ts
@@ -49,14 +49,31 @@ const testState = vi.hoisted(() => {
});
vi.mock("@effect/atom-react", () => ({
- useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }),
+ useAtomValue: (atom: unknown) =>
+ atom === "primary-settings"
+ ? { newWorktreesStartFromOrigin: false }
+ : new Map([
+ [
+ "environment-ssh",
+ {
+ settings: {
+ defaultThreadEnvMode: "local",
+ newWorktreesStartFromOrigin: false,
+ defaultModelSelection: null,
+ },
+ },
+ ],
+ ]),
}));
vi.mock("@t3tools/client-runtime/environment", () => ({
scopedProjectKey: () => "remote-project",
scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),
scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),
}));
-vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" }));
+vi.mock("@t3tools/contracts", () => ({
+ DEFAULT_RUNTIME_MODE: "default",
+ DEFAULT_SERVER_SETTINGS: {},
+}));
vi.mock("@t3tools/shared/threadEnvMode", () => ({
resolveDefaultThreadEnvMode: (input: {
readonly projectFile: "local" | "worktree" | null;
@@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({
useProjects: () => [],
useThread: () => null,
}));
-vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} }));
+vi.mock("../state/server", () => ({
+ environmentServerConfigsAtom: {},
+ primaryServerSettingsAtom: "primary-settings",
+}));
vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null }));
vi.mock("../uiStateStore", () => ({
legacyProjectCwdPreferenceKey: () => "remote-project",
diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts
index c26b25d1316b..78dfc1b13fe6 100644
--- a/apps/web/src/hooks/useHandleNewThread.ts
+++ b/apps/web/src/hooks/useHandleNewThread.ts
@@ -4,7 +4,12 @@ import {
scopeProjectRef,
scopeThreadRef,
} from "@t3tools/client-runtime/environment";
-import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";
+import {
+ DEFAULT_RUNTIME_MODE,
+ DEFAULT_SERVER_SETTINGS,
+ type ScopedProjectRef,
+ type ThreadId,
+} from "@t3tools/contracts";
import { useParams, useRouter } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
import {
@@ -30,7 +35,7 @@ import {
resolveNewThreadModelSelectionOverride,
} from "../lib/chatThreadActions";
import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
-import { primaryServerSettingsAtom } from "../state/server";
+import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server";
import { resolveThreadRouteTarget } from "../threadRoutes";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
import { useClientSettings } from "./useSettings";
@@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
}
export function useNewThreadHandler() {
- // New-thread defaults are a user preference, and the settings UI only ever
- // edits the primary environment's settings.json. Reading the target
- // environment's own settings here would silently reset remote projects to
- // the decoded defaults ("local" mode, current branch), since nothing can
- // set those values on a remote server.
+ const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom);
const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const router = useRouter();
@@ -83,6 +84,8 @@ export function useNewThreadHandler() {
// up again and finding whichever draft it happens to hold.
): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {
const projects = readProjects();
+ const targetServerSettings =
+ environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS;
const {
getComposerDraft,
getDraftSessionByLogicalProjectKey,
@@ -138,7 +141,8 @@ export function useNewThreadHandler() {
);
const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
resolveNewThreadModelSelectionOverride({
- projectDefaultSelection: project?.defaultModelSelection ?? null,
+ projectDefaultSelection:
+ project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null,
carrySelection: carryModelSelection,
carrySourceDraftId:
currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
@@ -157,7 +161,7 @@ export function useNewThreadHandler() {
project.workspaceRoot,
)
: null,
- globalDefault: primaryServerSettings.defaultThreadEnvMode,
+ globalDefault: targetServerSettings.defaultThreadEnvMode,
});
};
const logicalProjectKey = project
@@ -429,7 +433,13 @@ export function useNewThreadHandler() {
return { draftId, threadId };
})();
},
- [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router],
+ [
+ environmentServerConfigs,
+ getCurrentRouteTarget,
+ primaryServerSettings.newWorktreesStartFromOrigin,
+ projectGroupingSettings,
+ router,
+ ],
);
}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 5c796f3ab6c8..b1a9d0b9e04b 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -18,6 +18,7 @@ import { Route as ChatRouteImport } from './routes/_chat'
import { Route as ChatIndexRouteImport } from './routes/_chat.index'
import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
+import { Route as SettingsProjectsRouteImport } from './routes/settings.projects'
import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations'
import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
@@ -75,6 +76,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
path: '/providers',
getParentRoute: () => SettingsRoute,
} as any)
+const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
+ id: '/projects',
+ path: '/projects',
+ getParentRoute: () => SettingsRoute,
+} as any)
const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
id: '/keybindings',
path: '/keybindings',
@@ -154,6 +160,7 @@ export interface FileRoutesByFullPath {
'/settings/general': typeof SettingsGeneralRoute
'/settings/integrations': typeof SettingsIntegrationsRoute
'/settings/keybindings': typeof SettingsKeybindingsRoute
+ '/settings/projects': typeof SettingsProjectsRoute
'/settings/providers': typeof SettingsProvidersRoute
'/settings/source-control': typeof SettingsSourceControlRoute
'/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
@@ -175,6 +182,7 @@ export interface FileRoutesByTo {
'/settings/general': typeof SettingsGeneralRoute
'/settings/integrations': typeof SettingsIntegrationsRoute
'/settings/keybindings': typeof SettingsKeybindingsRoute
+ '/settings/projects': typeof SettingsProjectsRoute
'/settings/providers': typeof SettingsProvidersRoute
'/settings/source-control': typeof SettingsSourceControlRoute
'/': typeof ChatIndexRoute
@@ -199,6 +207,7 @@ export interface FileRoutesById {
'/settings/general': typeof SettingsGeneralRoute
'/settings/integrations': typeof SettingsIntegrationsRoute
'/settings/keybindings': typeof SettingsKeybindingsRoute
+ '/settings/projects': typeof SettingsProjectsRoute
'/settings/providers': typeof SettingsProvidersRoute
'/settings/source-control': typeof SettingsSourceControlRoute
'/_chat/': typeof ChatIndexRoute
@@ -224,6 +233,7 @@ export interface FileRouteTypes {
| '/settings/general'
| '/settings/integrations'
| '/settings/keybindings'
+ | '/settings/projects'
| '/settings/providers'
| '/settings/source-control'
| '/$environmentId/$threadId'
@@ -245,6 +255,7 @@ export interface FileRouteTypes {
| '/settings/general'
| '/settings/integrations'
| '/settings/keybindings'
+ | '/settings/projects'
| '/settings/providers'
| '/settings/source-control'
| '/'
@@ -268,6 +279,7 @@ export interface FileRouteTypes {
| '/settings/general'
| '/settings/integrations'
| '/settings/keybindings'
+ | '/settings/projects'
| '/settings/providers'
| '/settings/source-control'
| '/_chat/'
@@ -351,6 +363,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsProvidersRouteImport
parentRoute: typeof SettingsRoute
}
+ '/settings/projects': {
+ id: '/settings/projects'
+ path: '/projects'
+ fullPath: '/settings/projects'
+ preLoaderRoute: typeof SettingsProjectsRouteImport
+ parentRoute: typeof SettingsRoute
+ }
'/settings/keybindings': {
id: '/settings/keybindings'
path: '/keybindings'
@@ -462,6 +481,7 @@ interface SettingsRouteChildren {
SettingsGeneralRoute: typeof SettingsGeneralRoute
SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute
SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
+ SettingsProjectsRoute: typeof SettingsProjectsRoute
SettingsProvidersRoute: typeof SettingsProvidersRoute
SettingsSourceControlRoute: typeof SettingsSourceControlRoute
}
@@ -474,6 +494,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
SettingsGeneralRoute: SettingsGeneralRoute,
SettingsIntegrationsRoute: SettingsIntegrationsRoute,
SettingsKeybindingsRoute: SettingsKeybindingsRoute,
+ SettingsProjectsRoute: SettingsProjectsRoute,
SettingsProvidersRoute: SettingsProvidersRoute,
SettingsSourceControlRoute: SettingsSourceControlRoute,
}
diff --git a/apps/web/src/routes/projects.$projectKey.tsx b/apps/web/src/routes/projects.$projectKey.tsx
index 6ae03719c042..d636c0a953ef 100644
--- a/apps/web/src/routes/projects.$projectKey.tsx
+++ b/apps/web/src/routes/projects.$projectKey.tsx
@@ -1,15 +1,17 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
-import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel";
-
export const Route = createFileRoute("/projects/$projectKey")({
- beforeLoad: async ({ context }) => {
+ beforeLoad: async ({ context, params }) => {
if (
context.authGateState.status !== "authenticated" &&
context.authGateState.status !== "hosted-static"
) {
throw redirect({ to: "/pair", replace: true });
}
+ throw redirect({
+ to: "/settings/projects",
+ search: { project: params.projectKey, machine: undefined },
+ replace: true,
+ });
},
- component: () => ,
});
diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/settings.projects.tsx
new file mode 100644
index 000000000000..fa79f46fbb2c
--- /dev/null
+++ b/apps/web/src/routes/settings.projects.tsx
@@ -0,0 +1,27 @@
+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,
+});
+
+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/docs/user/project-settings.md b/docs/user/project-settings.md
index 747bc52c07ec..c76c18544df2 100644
--- a/docs/user/project-settings.md
+++ b/docs/user/project-settings.md
@@ -1,11 +1,28 @@
# Project settings
-Open **Settings → Projects** and select a project to change its preferences.
+Open **Settings → Projects**. The project and machine pickers start at **All projects** and
+**All machines**.
+
+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. Reset its row to inherit again. Changing a
+default preserves explicit project overrides. Workspace preferences in `t3.json` take precedence
+over machine defaults when the project has no explicit workspace override.
+
+Select a machine to limit edits to it. **All machines** writes defaults to connected machines;
+offline machines keep their previous values. Mixed values are indicated when selected machines
+or checkouts disagree. Browser access changes apply when an agent session next starts.
+
+Project grouping has a client-wide default across machines, with individual checkout overrides.
+Shared actions apply to inheriting projects; editing a project's actions creates an independent list.
+Reset that list to use shared actions again. Existing project actions are preserved.
+
+Project names, icons, removal, and importing actions from a checkout remain project-specific.
+When there are several checkouts, the checkout picker selects which actions and grouping to edit.
## Project icons
Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies
-to every checkout in the project group and appears on connected clients. Choose **Automatic** to
+to selected checkouts in the project group and appears on connected clients. Choose **Automatic** to
let T3 Code detect an icon again.
## Keep the default branch current
diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts
index 8cf0e3bc7f08..712138aab4c9 100644
--- a/packages/client-runtime/src/state/sharedSettings.test.ts
+++ b/packages/client-runtime/src/state/sharedSettings.test.ts
@@ -44,13 +44,19 @@ describe("splitSharedServerPatch", () => {
sidebarAutoSettleOnMerge: false,
continueThreadsAfterServerUpdate: true,
enableAgentBrowserAccess: false,
+ defaultThreadEnvMode: "worktree",
+ newWorktreesStartFromOrigin: true,
});
expect(sharedPatch).toEqual({
sidebarAutoSettleAfterDays: 7,
sidebarAutoSettleOnMerge: false,
continueThreadsAfterServerUpdate: true,
+ newWorktreesStartFromOrigin: true,
+ });
+ expect(localPatch).toEqual({
+ enableAgentBrowserAccess: false,
+ defaultThreadEnvMode: "worktree",
});
- expect(localPatch).toEqual({ enableAgentBrowserAccess: false });
});
});
@@ -60,7 +66,6 @@ describe("pickSharedServerSettings", () => {
Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(),
).toEqual([
"continueThreadsAfterServerUpdate",
- "defaultThreadEnvMode",
"newWorktreesStartFromOrigin",
"sidebarAutoSettleAfterDays",
"sidebarAutoSettleOnMerge",
@@ -206,7 +211,12 @@ describe("findSharedSettingsMismatches", () => {
environmentId: boxId,
label: "Remote Box",
syncEligible: true,
- settings: { ...primarySettings, enableAgentBrowserAccess: false },
+ settings: {
+ ...primarySettings,
+ enableAgentBrowserAccess: false,
+ defaultThreadEnvMode:
+ primarySettings.defaultThreadEnvMode === "local" ? "worktree" : "local",
+ },
},
],
});
diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts
index 128d5c25464b..0fd691a64bcd 100644
--- a/packages/client-runtime/src/state/sharedSettings.ts
+++ b/packages/client-runtime/src/state/sharedSettings.ts
@@ -24,7 +24,6 @@ const SHARED_SERVER_SETTING_KEYS = [
"continueThreadsAfterServerUpdate",
"sidebarAutoSettleAfterDays",
"sidebarAutoSettleOnMerge",
- "defaultThreadEnvMode",
"newWorktreesStartFromOrigin",
"sourceControlWritingStyle",
] as const satisfies ReadonlyArray;
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index ce9082477372..983e17b54370 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -2,7 +2,12 @@ import * as Effect from "effect/Effect";
import * as Duration from "effect/Duration";
import * as Schema from "effect/Schema";
import * as SchemaTransformation from "effect/SchemaTransformation";
-import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
+import {
+ ForwardCompatibleNullable,
+ ProjectId,
+ TrimmedNonEmptyString,
+ TrimmedString,
+} from "./baseSchemas.ts";
import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
import {
@@ -11,7 +16,7 @@ import {
DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
ProviderOptionSelections,
} from "./model.ts";
-import { ModelSelection } from "./orchestration.ts";
+import { ModelSelection, ProjectScript } from "./orchestration.ts";
import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
import {
DEFAULT_PREVIEW_APPEARANCE,
@@ -856,6 +861,22 @@ export const ServerSettings = Schema.Struct({
* between a desktop window and a phone attached to the same server.
*/
enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+ projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe(
+ Schema.withDecodingDefault(Effect.succeed({})),
+ ),
+ defaultAutoPull: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ defaultProjectScripts: Schema.Array(ProjectScript).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ ),
+ projectScriptOverrides: Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))).pipe(
+ Schema.withDecodingDefault(Effect.succeed({})),
+ ),
+ projectAutoPullOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe(
+ Schema.withDecodingDefault(Effect.succeed({})),
+ ),
+ defaultModelSelection: Schema.NullOr(ModelSelection).pipe(
+ Schema.withDecodingDefault(Effect.succeed(null)),
+ ),
sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
),
@@ -1116,6 +1137,18 @@ export const ServerSettingsPatch = Schema.Struct({
enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
+ projectAgentBrowserAccessOverrides: Schema.optionalKey(
+ Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
+ ),
+ defaultAutoPull: Schema.optionalKey(Schema.Boolean),
+ defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)),
+ projectScriptOverrides: Schema.optionalKey(
+ Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))),
+ ),
+ projectAutoPullOverrides: Schema.optionalKey(
+ Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
+ ),
+ defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
backgroundActivity: Schema.optionalKey(
diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts
index 199a55bf3cbf..4d98e36b4d70 100644
--- a/packages/shared/src/projectScripts.ts
+++ b/packages/shared/src/projectScripts.ts
@@ -1,4 +1,24 @@
-import type { ProjectScript } from "@t3tools/contracts";
+import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts";
+
+/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */
+export function resolveProjectScripts(
+ settings: Pick,
+ 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)
+ );
+}
+
+export function projectScriptsInheritDefaults(
+ settings: Pick,
+ project: { id: ProjectId; scripts: readonly ProjectScript[] },
+): boolean {
+ const override = settings.projectScriptOverrides[project.id];
+ return override === null || (override === undefined && project.scripts.length === 0);
+}
interface ProjectScriptRuntimeEnvInput {
project: {
diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts
index 31f056c211e9..a5e428fcdaac 100644
--- a/packages/shared/src/serverSettings.test.ts
+++ b/packages/shared/src/serverSettings.test.ts
@@ -1,5 +1,6 @@
import {
DEFAULT_SERVER_SETTINGS,
+ ProjectId,
ProviderDriverKind,
ProviderInstanceId,
UsageLimitSourceId,
@@ -9,14 +10,181 @@ import * as Duration from "effect/Duration";
import { describe, expect, it } from "vite-plus/test";
import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts";
import { createModelSelection } from "./model.ts";
+import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";
import {
applyServerSettingsPatch,
isModelSelectionProviderEnabled,
parsePersistedServerObservabilitySettings,
resolveSourceControlWriterModelSelection,
+ resolveProjectAgentBrowserAccess,
+ resolveProjectAutoPull,
} from "./serverSettings.ts";
describe("serverSettings helpers", () => {
+ it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => {
+ const project = { id: ProjectId.make("project-actions"), scripts: [] };
+ const action = {
+ id: "check",
+ name: "Check",
+ command: "npm test",
+ icon: "play" as const,
+ runOnWorktreeCreate: false,
+ };
+ const defaults = applyServerSettingsPatch(DEFAULT_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);
+ const disabled = applyServerSettingsPatch(defaults, {
+ projectScriptOverrides: { [project.id]: [] },
+ });
+ expect(resolveProjectScripts(disabled, project)).toEqual([]);
+ expect(projectScriptsInheritDefaults(disabled, project)).toBe(false);
+ const changedDefault = applyServerSettingsPatch(disabled, {
+ defaultProjectScripts: [{ ...action, command: "npm run build" }],
+ });
+ expect(resolveProjectScripts(changedDefault, project)).toEqual([]);
+ const reset = applyServerSettingsPatch(changedDefault, {
+ projectScriptOverrides: { [project.id]: null },
+ });
+ expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts);
+ expect(projectScriptsInheritDefaults(reset, existing)).toBe(true);
+ expect(
+ resolveProjectScripts(
+ applyServerSettingsPatch(reset, { defaultProjectScripts: [] }),
+ existing,
+ ),
+ ).toEqual([]);
+ });
+
+ it("preserves other projects' actions when overriding, clearing, or resetting one project", () => {
+ const firstProject = { id: ProjectId.make("first-project"), scripts: [] };
+ const secondProject = { id: ProjectId.make("second-project"), scripts: [] };
+ const defaultAction = {
+ id: "check",
+ name: "Check",
+ command: "npm test",
+ icon: "play" as const,
+ runOnWorktreeCreate: false,
+ };
+ const firstAction = { ...defaultAction, command: "npm run lint" };
+ const secondAction = { ...defaultAction, command: "npm run build" };
+ const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ defaultProjectScripts: [defaultAction],
+ projectScriptOverrides: { [firstProject.id]: [firstAction] },
+ });
+ const secondUpdate = applyServerSettingsPatch(firstUpdate, {
+ projectScriptOverrides: { [secondProject.id]: [secondAction] },
+ });
+ expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]);
+ expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]);
+
+ const cleared = applyServerSettingsPatch(secondUpdate, {
+ projectScriptOverrides: { [firstProject.id]: [] },
+ });
+ expect(resolveProjectScripts(cleared, firstProject)).toEqual([]);
+ expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]);
+
+ const reset = applyServerSettingsPatch(cleared, {
+ projectScriptOverrides: { [firstProject.id]: null },
+ });
+ expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([
+ defaultAction,
+ ]);
+ expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]);
+ expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]);
+ });
+
+ it("inherits automatic pull while preserving legacy opt-ins and explicit overrides", () => {
+ const projectId = ProjectId.make("project-pull");
+ expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, false)).toBe(false);
+ expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, true)).toBe(true);
+ const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultAutoPull: true });
+ expect(resolveProjectAutoPull(enabled, projectId, false)).toBe(true);
+ const overridden = applyServerSettingsPatch(enabled, {
+ projectAutoPullOverrides: { [projectId]: false },
+ });
+ expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false);
+ const reset = applyServerSettingsPatch(overridden, {
+ projectAutoPullOverrides: { [projectId]: null },
+ });
+ expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true);
+ const disabled = applyServerSettingsPatch(reset, {
+ defaultAutoPull: false,
+ projectAutoPullOverrides: { [projectId]: true },
+ });
+ expect(resolveProjectAutoPull(disabled, projectId, false)).toBe(true);
+ expect(resolveProjectAutoPull(disabled, ProjectId.make("other-project"), false)).toBe(false);
+ });
+
+ it("inherits browser access and restores inheritance when a project override is removed", () => {
+ const projectId = ProjectId.make("project-browser");
+ const otherProjectId = ProjectId.make("other-project");
+ const overridden = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ projectAgentBrowserAccessOverrides: { [projectId]: false },
+ });
+ expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false);
+ expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true);
+ const reset = applyServerSettingsPatch(overridden, {
+ projectAgentBrowserAccessOverrides: { [projectId]: null },
+ });
+ expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true);
+ const enabled = applyServerSettingsPatch(reset, {
+ enableAgentBrowserAccess: false,
+ projectAgentBrowserAccessOverrides: { [projectId]: true },
+ });
+ expect(resolveProjectAgentBrowserAccess(enabled, projectId)).toBe(true);
+ expect(resolveProjectAgentBrowserAccess(enabled, otherProjectId)).toBe(false);
+ });
+
+ it("preserves other projects' boolean overrides across separate updates and resets", () => {
+ const firstProjectId = ProjectId.make("first-project");
+ const secondProjectId = ProjectId.make("second-project");
+ const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ defaultAutoPull: true,
+ projectAutoPullOverrides: { [firstProjectId]: false },
+ projectAgentBrowserAccessOverrides: { [firstProjectId]: false },
+ });
+ const secondUpdate = applyServerSettingsPatch(firstUpdate, {
+ projectAutoPullOverrides: { [secondProjectId]: false },
+ projectAgentBrowserAccessOverrides: { [secondProjectId]: false },
+ });
+ for (const projectId of [firstProjectId, secondProjectId]) {
+ expect(resolveProjectAutoPull(secondUpdate, projectId, false)).toBe(false);
+ expect(resolveProjectAgentBrowserAccess(secondUpdate, projectId)).toBe(false);
+ }
+
+ const reset = applyServerSettingsPatch(secondUpdate, {
+ projectAutoPullOverrides: { [firstProjectId]: null },
+ projectAgentBrowserAccessOverrides: { [firstProjectId]: null },
+ });
+ expect(resolveProjectAutoPull(reset, firstProjectId, false)).toBe(true);
+ expect(resolveProjectAgentBrowserAccess(reset, firstProjectId)).toBe(true);
+ expect(resolveProjectAutoPull(reset, secondProjectId, false)).toBe(false);
+ expect(resolveProjectAgentBrowserAccess(reset, secondProjectId)).toBe(false);
+ expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined();
+ expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined();
+ expect(resolveProjectAutoPull(secondUpdate, firstProjectId, false)).toBe(false);
+ expect(resolveProjectAgentBrowserAccess(secondUpdate, firstProjectId)).toBe(false);
+ });
+
+ it("replaces and clears conversation model defaults without retaining old options", () => {
+ const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
+ { id: "reasoningEffort", value: "high" },
+ ]),
+ });
+ const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "sonnet");
+ const updated = applyServerSettingsPatch(current, { defaultModelSelection: selection });
+ expect(updated.defaultModelSelection).toEqual(selection);
+ expect(
+ applyServerSettingsPatch(updated, { defaultModelSelection: null }).defaultModelSelection,
+ ).toBeNull();
+ });
+
it("ignores missing and blank persisted observability URLs", () => {
expect(parsePersistedServerObservabilitySettings("{}")).toEqual({
otlpTracesUrl: undefined,
diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts
index dc50da2d7627..f969e4412c30 100644
--- a/packages/shared/src/serverSettings.ts
+++ b/packages/shared/src/serverSettings.ts
@@ -3,6 +3,7 @@ import {
isProviderAvailable,
resolveProviderInstanceEnabled,
type ModelSelection,
+ type ProjectId,
type ProviderDriverKind,
type ServerProvider,
ServerSettings,
@@ -23,6 +24,27 @@ import {
const ServerSettingsJson = fromLenientJson(ServerSettings);
const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
+export function resolveProjectAgentBrowserAccess(
+ settings: Pick,
+ projectId: ProjectId,
+): boolean {
+ return (
+ settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess
+ );
+}
+
+export function resolveProjectAutoPull(
+ settings: Pick,
+ projectId: ProjectId,
+ legacyAutoPull: boolean | undefined,
+): boolean {
+ // Existing opt-ins stay enabled until explicitly overridden or reset.
+ return (
+ settings.projectAutoPullOverrides[projectId] ??
+ (legacyAutoPull === true || settings.defaultAutoPull)
+ );
+}
+
type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
const getLegacyProviderSettings = (
@@ -151,6 +173,8 @@ export function applyServerSettingsPatch(
// Merged per entry below; its `null` removals must not reach deepMerge.
usageLimitSources: usageLimitSourcesPatch,
usagePriceOverrides: usagePriceOverridesPatch,
+ projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch,
+ projectAutoPullOverrides: projectAutoPullOverridesPatch,
...patchForMerge
} = patch;
const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
@@ -207,6 +231,36 @@ export function applyServerSettingsPatch(
...(patch.providerInstances !== undefined
? { providerInstances: patch.providerInstances }
: {}),
+ ...(projectAgentBrowserAccessOverridesPatch !== undefined
+ ? {
+ projectAgentBrowserAccessOverrides: mergeSettingsEntries(
+ current.projectAgentBrowserAccessOverrides,
+ projectAgentBrowserAccessOverridesPatch,
+ ),
+ }
+ : {}),
+ ...(projectAutoPullOverridesPatch !== undefined
+ ? {
+ projectAutoPullOverrides: mergeSettingsEntries(
+ current.projectAutoPullOverrides,
+ projectAutoPullOverridesPatch,
+ ),
+ }
+ : {}),
+ ...(patch.defaultModelSelection !== undefined
+ ? { defaultModelSelection: patch.defaultModelSelection }
+ : {}),
+ ...(patch.defaultProjectScripts !== undefined
+ ? { defaultProjectScripts: patch.defaultProjectScripts }
+ : {}),
+ ...(patch.projectScriptOverrides !== undefined
+ ? {
+ projectScriptOverrides: {
+ ...current.projectScriptOverrides,
+ ...patch.projectScriptOverrides,
+ },
+ }
+ : {}),
...(usageLimitSourcesPatch !== undefined
? {
usageLimitSources: mergeSettingsEntries(