From f707b5a635d9d73321ae9ebd58a98a7ca74704cf Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:58:30 +0000 Subject: [PATCH 01/11] feat(web): add machine tabs to project settings --- .../settings/ProjectSettingsPanel.tsx | 141 ++++++++++++++---- docs/user/project-settings.md | 4 + 2 files changed, 112 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 7be0f15cd5c2..e9827a2639c8 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -12,21 +12,29 @@ import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; -import type { - ContextMenuItem, - ModelSelection, - ProjectIconOverride, - ProviderDriverKind, - SidebarProjectGroupingMode, - T3ProjectFileScript, - ThreadEnvMode, +import { + resolveEnvironmentMachineKind, + type ContextMenuItem, + type ModelSelection, + type ProjectIconOverride, + type ProviderDriverKind, + type SidebarProjectGroupingMode, + type T3ProjectFileScript, + type ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; -import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; +import { + ChevronDownIcon, + CopyIcon, + Layers3Icon, + PlusIcon, + SettingsIcon, + Trash2Icon, +} from "lucide-react"; import { lazy, Suspense, @@ -77,6 +85,7 @@ 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 { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { ProjectFavicon } from "../ProjectFavicon"; import { EMPTY_PROJECT_SCRIPT_INPUT, @@ -98,6 +107,7 @@ import { MenuTrigger, } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { ScrollArea } from "../ui/scroll-area"; import { SidebarInset } from "../ui/sidebar"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -120,6 +130,7 @@ import { ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; +import { providerSettingsTabClassName } from "./providerSettingsTabs"; const ProjectIconPickerDialog = lazy(() => import("./ProjectIconPickerDialog").then((module) => ({ @@ -304,6 +315,11 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useEnvironments(); + const environmentById = useMemo( + () => new Map(environments.map((environment) => [environment.environmentId, environment])), + [environments], + ); const representative = group.memberProjects.find( (member) => member.environmentId === group.environmentId && member.id === group.id, @@ -506,10 +522,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); // ----- checkout selection and scripts ----- - const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey); + const hasMultipleCheckouts = group.memberProjects.length > 1; + const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null); const selectedCheckout = group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? representative; + const allMachinesSelected = hasMultipleCheckouts && selectedCheckoutKey === null; + const checkoutLabelCounts = useMemo(() => { + const counts = new Map(); + for (const member of group.memberProjects) { + const label = member.environmentLabel ?? "This machine"; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return counts; + }, [group.memberProjects]); const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); @@ -809,7 +835,73 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { return ( <> - + {hasMultipleCheckouts ? ( + +
+ + {group.memberProjects.map((member) => { + const label = member.environmentLabel ?? "This machine"; + const selected = + !allMachinesSelected && + member.physicalProjectKey === selectedCheckout.physicalProjectKey; + const showPathLabel = (checkoutLabelCounts.get(label) ?? 0) > 1; + const machine = resolveEnvironmentMachineKind( + environmentById.get(member.environmentId)?.serverConfig ?? null, + ); + return ( + + setSelectedCheckoutKey(member.physicalProjectKey)} + > + + {label} + {showPathLabel ? ( + + · {member.workspaceRoot} + + ) : null} + + } + /> + + {label} · {member.workspaceRoot} + + + ); + })} +
+
+ ) : null} + + setSelectedCheckoutKey(String(value))} - > - - {selectedCheckoutLabel} - - - {group.memberProjects.map((member) => ( - - {member.environmentLabel ?? "This machine"} · {member.workspaceRoot} - - ))} - - - } + className={!hasMultipleCheckouts || !allMachinesSelected ? undefined : "hidden"} >
@@ -1241,7 +1313,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ) : null} - + 1 ? "Remove this project everywhere" : "Remove project" diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 747bc52c07ec..561d45255167 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -2,6 +2,10 @@ Open **Settings → Projects** and select a project to change its preferences. +When a project group has more than one checkout, select **All machines** to edit its shared name, +icon, model, workspace, and pull behavior. Select a machine tab to view that checkout's path, edit +its grouping and actions, or remove it. + ## Project icons Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies From 9c3b4c975bcf6d9755c9880e201ea0ccdb2bd465 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:07:04 +0000 Subject: [PATCH 02/11] fix(web): handle stale project checkout selection --- .../web/src/components/settings/ProjectSettingsPanel.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index e9827a2639c8..cb88d786a307 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -524,10 +524,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { // ----- checkout selection and scripts ----- const hasMultipleCheckouts = group.memberProjects.length > 1; const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null); - const selectedCheckout = - group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? - representative; - const allMachinesSelected = hasMultipleCheckouts && selectedCheckoutKey === null; + const selectedCheckoutMatch = group.memberProjects.find( + (member) => member.physicalProjectKey === selectedCheckoutKey, + ); + const selectedCheckout = selectedCheckoutMatch ?? representative; + const allMachinesSelected = hasMultipleCheckouts && selectedCheckoutMatch === undefined; const checkoutLabelCounts = useMemo(() => { const counts = new Map(); for (const member of group.memberProjects) { From 122cec0ac49b5c048a7866a3776db79428ee35bf Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:30:03 +0000 Subject: [PATCH 03/11] fix(web): simplify project machine tabs --- .../settings/ProjectSettingsPanel.tsx | 49 ++++++------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index cb88d786a307..9263ff6bd552 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -529,14 +529,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); const selectedCheckout = selectedCheckoutMatch ?? representative; const allMachinesSelected = hasMultipleCheckouts && selectedCheckoutMatch === undefined; - const checkoutLabelCounts = useMemo(() => { - const counts = new Map(); - for (const member of group.memberProjects) { - const label = member.environmentLabel ?? "This machine"; - counts.set(label, (counts.get(label) ?? 0) + 1); - } - return counts; - }, [group.memberProjects]); const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); @@ -861,38 +853,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const selected = !allMachinesSelected && member.physicalProjectKey === selectedCheckout.physicalProjectKey; - const showPathLabel = (checkoutLabelCounts.get(label) ?? 0) > 1; const machine = resolveEnvironmentMachineKind( environmentById.get(member.environmentId)?.serverConfig ?? null, ); return ( - - setSelectedCheckoutKey(member.physicalProjectKey)} - > - - {label} - {showPathLabel ? ( - - · {member.workspaceRoot} - - ) : null} - - } + ); })}
@@ -901,6 +879,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { Date: Sat, 5 Sep 2026 05:16:03 +0000 Subject: [PATCH 04/11] feat(settings): add project defaults and scoped overrides --- .../features/threads/ThreadRouteScreen.tsx | 20 +- .../threads/new-task-flow-provider.tsx | 4 +- .../project/ProjectSetupScriptRunner.test.ts | 59 ++ .../src/project/ProjectSetupScriptRunner.ts | 26 +- .../provider/Layers/ProviderService.test.ts | 79 +- .../src/provider/Layers/ProviderService.ts | 22 +- apps/server/src/server.ts | 6 +- apps/server/src/serverRuntimeStartup.test.ts | 12 +- apps/server/src/serverRuntimeStartup.ts | 12 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 17 +- apps/web/src/components/ChatView.tsx | 98 +-- .../src/components/chat/DraftHeroHeadline.tsx | 9 +- .../DesktopAppActivationCoordinator.tsx | 7 +- .../settings/IntegrationsSettings.tsx | 40 +- .../settings/ProjectActionsList.tsx | 69 ++ .../ProjectDefaultActionsSettings.tsx | 234 ++++++ .../settings/ProjectDefaultsSettings.tsx | 461 ++++++++++++ .../settings/ProjectSettingsPanel.tsx | 687 +++++++++--------- .../components/settings/ProjectsSettings.tsx | 159 ++++ .../components/settings/SettingsPanels.tsx | 87 +-- .../settings/SettingsSidebarNav.tsx | 2 + .../components/settings/settingsLayout.tsx | 10 +- .../src/components/settings/settingsSearch.ts | 15 +- apps/web/src/hooks/useHandleNewThread.test.ts | 21 +- apps/web/src/hooks/useHandleNewThread.ts | 29 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/projects.$projectKey.tsx | 10 +- apps/web/src/routes/settings.projects.tsx | 27 + docs/user/project-settings.md | 23 +- packages/contracts/src/settings.ts | 33 +- packages/shared/src/projectScripts.ts | 22 +- packages/shared/src/serverSettings.test.ts | 95 +++ packages/shared/src/serverSettings.ts | 37 + 33 files changed, 1919 insertions(+), 534 deletions(-) create mode 100644 apps/web/src/components/settings/ProjectActionsList.tsx create mode 100644 apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx create mode 100644 apps/web/src/components/settings/ProjectDefaultsSettings.tsx create mode 100644 apps/web/src/components/settings/ProjectsSettings.tsx create mode 100644 apps/web/src/routes/settings.projects.tsx diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index df9486e8556a..6d2fb5e1d1c0 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -7,12 +7,21 @@ import { } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; -import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ThreadId, + type ProjectScript, +} from "@t3tools/contracts"; import { requestOlderThreadTurns, threadHasOlderTurns, } from "@t3tools/client-runtime/state/threads"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; @@ -627,7 +636,12 @@ function ThreadRouteContent( gitOperationLabel: gitState.gitOperationLabel, canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), - projectScripts: selectedThreadProject?.scripts ?? [], + projectScripts: selectedThreadProject + ? resolveProjectScripts( + routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + selectedThreadProject, + ) + : [], terminalSessions: terminalMenuSessions, showDirectFileControl: layout.usesSplitView, onOpenTerminal: handleOpenTerminal, 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 6d87f284ebda..f7114c22f5f7 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -426,7 +426,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, + selectedProject?.defaultModelSelection ?? + selectedEnvironmentServerConfig?.settings.defaultModelSelection ?? + null, ); const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 46fee8d8add6..108db2a07d64 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; +import { layerTest as settingsLayerTest } from "../serverSettings.ts"; const isProjectSetupScriptOperationError = Schema.is( ProjectSetupScriptRunner.ProjectSetupScriptOperationError, @@ -74,6 +75,64 @@ const testLayer = ( ); describe("ProjectSetupScriptRunner", () => { + it.effect("runs the inherited machine setup action in the checkout's worktree", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-default-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + expect(result).toMatchObject({ status: "started", scriptId: "default-setup" }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a" }, + }); + expect(write).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + data: "npm install\r", + }); + }).pipe( + Effect.provide( + testLayer(makeProject([]), { open, write }).pipe( + Layer.provide( + settingsLayerTest({ + defaultProjectScripts: [ + { + id: "default-setup", + name: "Setup", + command: "npm install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }), + ), + ), + ), + ); + }); + it.effect("returns no-script when no setup script exists", () => { const open = vi.fn(() => Effect.die("unexpected open")); const write = vi.fn(() => Effect.die("unexpected write")); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 41bf0fabf489..9392c10e6e97 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,9 @@ -import { ProjectId } from "@t3tools/contracts"; -import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { DEFAULT_SERVER_SETTINGS, ProjectId } from "@t3tools/contracts"; +import { + projectScriptRuntimeEnv, + resolveProjectScripts, + setupProjectScript, +} from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -8,6 +12,7 @@ import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as TerminalManager from "../terminal/Manager.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; export interface ProjectSetupScriptRunnerResultNoScript { readonly status: "no-script"; @@ -40,7 +45,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readSettings", + cause, + }), + ), + ) + : DEFAULT_SERVER_SETTINGS; + const script = setupProjectScript(resolveProjectScripts(settings, project)); if (!script) { return { status: "no-script", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 5e59960685a7..a621d2b7206d 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -19,6 +19,8 @@ import { EnvironmentId, EventId, MessageId, + OrchestrationThreadShell, + ProjectId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderDriverKind, ProviderInstanceId, @@ -73,6 +75,7 @@ import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( @@ -4102,8 +4105,13 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { describe("agent browser access", () => { const revokedThreads: Array = []; + const projectId = ProjectId.make("project-browser-access"); - const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) => + const startSessionWith = ( + enableAgentBrowserAccess: boolean, + threadId: ThreadId, + projectOverride?: boolean, + ) => Effect.gen(function* () { const issued: Array = []; const codex = makeFakeCodexAdapter(); @@ -4117,6 +4125,48 @@ describe("agent browser access", () => { const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); + const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + 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"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadShellById: (requestedThreadId) => + Effect.gen(function* () { + assert.equal(requestedThreadId, threadId); + return Option.some( + yield* Schema.decodeUnknownEffect(OrchestrationThreadShell)({ + 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 providerLayer = makeProviderServiceLive({ issueMcpCredential: (request) => Effect.sync(() => { @@ -4127,7 +4177,14 @@ describe("agent browser access", () => { }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess })), + Layer.provide(projectionLayer), + Layer.provide( + ServerSettings.ServerSettingsService.layerTest({ + enableAgentBrowserAccess, + projectAgentBrowserAccessOverrides: + projectOverride === undefined ? {} : { [projectId]: projectOverride }, + }), + ), Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( @@ -4185,4 +4242,22 @@ describe("agent browser access", () => { assert.deepEqual(issued, [threadId]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("withholds and revokes MCP credentials when the project disables browser access", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-off"); + revokedThreads.length = 0; + const issued = yield* startSessionWith(true, threadId, false); + assert.deepEqual(issued, []); + assert.deepEqual(revokedThreads, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("requests an MCP credential when the project overrides browser access to on", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-on"); + const issued = yield* startSessionWith(false, threadId, true); + assert.deepEqual(issued, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2098cacda5eb..e41d621bb78c 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -32,6 +32,7 @@ import { import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -67,6 +68,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const isModelSelection = Schema.is(ModelSelection); interface PendingCompaction { @@ -314,6 +316,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const revokeMcpCredential = @@ -704,8 +709,19 @@ 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 = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.enableAgentBrowserAccess), + const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( + function* (threadId: ThreadId) { + const settings = yield* serverSettings.getSettings; + if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { + return settings.enableAgentBrowserAccess; + } + // 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); + }, Effect.catch((cause) => Effect.logWarning( "Could not read server settings; withholding agent browser access for this session.", @@ -716,7 +732,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => Effect.gen(function* () { - if (!(yield* agentBrowserAccessEnabled)) { + if (!(yield* agentBrowserAccessEnabled(threadId))) { // Revoke as well as clear. Every other prepare path reaches // `issueActiveMcpCredential`, which revokes the thread first, so // skipping it here would leave a previously issued bearer token valid diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c0be0c444573..4abe43d8a631 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -317,7 +317,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( ); const GitManagerLayerLive = GitManager.layer.pipe( - Layer.provideMerge(ProjectSetupScriptRunner.layer), + Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), @@ -353,7 +353,9 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( Layer.provide(GitWorkflowLayerLive), - Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer), + Layer.provide( + VcsStatusBroadcaster.autoPullPolicyLayer.pipe(Layer.provide(ServerSettingsLayerLive)), + ), ), ), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b53e1843c226..81f46d198b00 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -48,7 +48,7 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; const project = (workspaceRoot: string, autoPull = true) => - ({ workspaceRoot, autoPull }) as never; + ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; yield* ServerRuntimeStartup.autoPullProjects([ project("/clean"), @@ -60,6 +60,16 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che ]).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 }, + }, + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + assert.deepStrictEqual(pulled, ["/inherited"]); }), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 064796b2810b..9df3d29d922c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_SERVER_SETTINGS, type ModelSelection, type OrchestrationProjectShell, ProjectId, @@ -9,6 +10,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -638,12 +640,14 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, + settings: Pick = + DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => project.autoPull === true) + .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) .map((project) => project.workspaceRoot), ), ]; @@ -714,7 +718,11 @@ export const make = (options?: StartupOptions) => const reactorScope = yield* Scope.make("sequential"); const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe( - Effect.flatMap((snapshot) => autoPullProjects(snapshot.projects)), + Effect.flatMap((snapshot) => + serverSettings.getSettings.pipe( + Effect.flatMap((settings) => autoPullProjects(snapshot.projects, settings)), + ), + ), Effect.catch((cause) => Effect.logWarning("Failed to load projects for automatic pull", { cause }), ), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 04d320c03bf9..c00a07f2a7a9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,10 +22,12 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect( VcsAutoPullPolicy, Effect.gen(function* () { const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettings.ServerSettingsService; return { - isEnabled: (cwd: string) => - snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe( - Effect.map((project) => project._tag === "Some" && project.value.autoPull === true), - Effect.orElseSucceed(() => false), - ), + isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( + function* (cwd: string) { + 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); + }, + Effect.orElseSucceed(() => false), + ), }; }), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfbeb3aa7f72..6a398709148b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -46,7 +46,11 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -266,13 +270,11 @@ import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../revi import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, - primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; @@ -1376,7 +1378,9 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); - const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); @@ -1464,10 +1468,6 @@ export default function ChatView(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); - // New-thread defaults live in the primary environment's settings.json (the - // settings UI never writes to remote environments), so read them from the - // primary server rather than the thread's environment. - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -1740,10 +1740,17 @@ export default function ChatView(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + fallbackDraftProject?.defaultModelSelection ?? + settings.defaultModelSelection ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], + [ + draftThread, + fallbackDraftProject?.defaultModelSelection, + settings.defaultModelSelection, + threadId, + ], ); // Promotion is data-driven: the draft route keeps rendering while the // server thread (same pre-allocated ref) starts, so live state must not @@ -1969,6 +1976,12 @@ export default function ChatView(props: ChatViewProps) { [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); + const activeProjectScripts = useMemo( + () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), + [activeProject, settings], + ); + const activeProjectDefaultModelSelection = + activeProject?.defaultModelSelection ?? settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -2017,8 +2030,8 @@ export default function ChatView(props: ChatViewProps) { [activeProjectKey], ); const configuredPreviewUrls = useMemo( - () => getConfiguredPreviewUrls(activeProject?.scripts), - [activeProject?.scripts], + () => getConfiguredPreviewUrls(activeProjectScripts), + [activeProjectScripts], ); useEffect(() => { @@ -2256,7 +2269,7 @@ export default function ChatView(props: ChatViewProps) { const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? + activeProjectDefaultModelSelection?.instanceId ?? null; const lockedProvider = deriveLockedProvider({ thread: activeThread, @@ -2495,14 +2508,14 @@ export default function ChatView(props: ChatViewProps) { selectedProviderByThreadId, activeThread?.session?.providerInstanceId, activeThread?.modelSelection.instanceId, - activeProject?.defaultModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, ], lockedProvider, lockedInstanceId: activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId, }), [ - activeProject?.defaultModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, activeThread?.modelSelection.instanceId, activeThread?.session?.providerInstanceId, lockedProvider, @@ -3524,11 +3537,15 @@ export default function ChatView(props: ChatViewProps) { keybindingCommand: KeybindingCommand; }): Promise> => { const updateResult = mapAtomCommandResult( - await updateProject({ + await updateProjectScriptSettings({ environmentId, input: { - projectId: input.projectId, - scripts: input.nextScripts, + patch: { + projectScriptOverrides: { + ...settings.projectScriptOverrides, + [input.projectId]: input.nextScripts, + }, + }, }, }), () => undefined, @@ -3553,7 +3570,7 @@ export default function ChatView(props: ChatViewProps) { } return updateResult; }, - [environmentId, updateProject, upsertKeybinding], + [environmentId, settings.projectScriptOverrides, updateProjectScriptSettings, upsertKeybinding], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -3562,28 +3579,28 @@ export default function ChatView(props: ChatViewProps) { } const nextId = nextProjectScriptId( input.name, - activeProject.scripts.map((script) => script.id), + activeProjectScripts.map((script) => script.id), ); const nextScript = buildProjectScript(nextId, input); const nextScripts = input.runOnWorktreeCreate ? [ - ...activeProject.scripts.map((script) => + ...activeProjectScripts.map((script) => script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ), nextScript, ] - : [...activeProject.scripts, nextScript]; + : [...activeProjectScripts, nextScript]; return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(nextId), }); }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const updateProjectScript = useCallback( async ( @@ -3593,13 +3610,13 @@ export default function ChatView(props: ChatViewProps) { if (!activeProject) { return AsyncResult.success(undefined); } - const existingScript = activeProject.scripts.find((script) => script.id === scriptId); + const existingScript = activeProjectScripts.find((script) => script.id === scriptId); if (!existingScript) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } const updatedScript = buildProjectScript(existingScript.id, input); - const nextScripts = activeProject.scripts.map((script) => + const nextScripts = activeProjectScripts.map((script) => script.id === scriptId ? updatedScript : input.runOnWorktreeCreate @@ -3610,27 +3627,27 @@ export default function ChatView(props: ChatViewProps) { return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(scriptId), }); }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const deleteProjectScript = useCallback( async (scriptId: string): Promise> => { if (!activeProject) { return AsyncResult.success(undefined); } - const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); + const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId); - const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; + const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name; const result = await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: null, keybindingCommand: commandForProjectScript(scriptId), @@ -3652,7 +3669,7 @@ export default function ChatView(props: ChatViewProps) { } return result; }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const handleRuntimeModeChange = useCallback( @@ -4938,7 +4955,7 @@ export default function ChatView(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - primaryServerSettings.newWorktreesStartFromOrigin) + settings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -5925,7 +5942,7 @@ export default function ChatView(props: ChatViewProps) { const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; - const script = activeProject.scripts.find((entry) => entry.id === scriptId); + const script = activeProjectScripts.find((entry) => entry.id === scriptId); if (!script) return; event.preventDefault(); event.stopPropagation(); @@ -5936,6 +5953,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, activeRightPanelSurface, + activeProjectScripts, addTerminalSurface, activeThreadRef, activeThreadPinned, @@ -6594,7 +6612,7 @@ export default function ChatView(props: ChatViewProps) { const title = truncate(titleSeed); const threadCreateModelSelection = createModelSelection( ctxSelectedModelSelection.instanceId, - ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, + ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL, ctxSelectedModelSelection.options, ); @@ -7430,7 +7448,7 @@ export default function ChatView(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -7442,7 +7460,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - primaryServerSettings.newWorktreesStartFromOrigin, + settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, @@ -7725,7 +7743,7 @@ export default function ChatView(props: ChatViewProps) { activeProjectFaviconPath={activeProject?.faviconPath ?? null} activeProjectIcon={activeProject?.projectIcon ?? null} openInCwd={gitCwd} - activeProjectScripts={activeProject?.scripts} + activeProjectScripts={activeProjectScripts} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null } @@ -7952,9 +7970,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} - activeProjectDefaultModelSelection={ - activeProject?.defaultModelSelection - } + activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} compactThreadUnavailable={compactThreadUnavailable} diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 04bbeb6ce49b..98f9caa2300a 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -149,8 +149,13 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - if (project.defaultModelSelection) { - setModelSelection(draftId, project.defaultModelSelection, { + const defaultModelSelection = + project.defaultModelSelection ?? + environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings.defaultModelSelection; + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, }); } diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index e97a46a2a258..3e941e69dd53 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -6,7 +6,6 @@ import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; import { newProjectId } from "../../lib/utils"; -import { resolveDefaultProviderModelSelection } from "../../providerInstances"; import { readProjects, waitForProject } from "../../state/entities"; import { usePrimaryEnvironment } from "../../state/environments"; import { projectEnvironment } from "../../state/projects"; @@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() { ) ?? null, createProject: async (environmentId, workspaceRoot) => { const projectId = newProjectId(); - const providers = - primaryEnvironment?.environmentId === environmentId - ? (primaryEnvironment.serverConfig?.providers ?? []) - : []; const result = await createProject({ environmentId, input: { @@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() { title: inferProjectTitleFromPath(workspaceRoot), workspaceRoot, createWorkspaceRootIfMissing: false, - defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), + defaultModelSelection: null, }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 5b1d9ba5e21e..e6a0e15bf91f 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -20,7 +20,6 @@ import { DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, - DEFAULT_UNIFIED_SETTINGS, DEFAULT_PREVIEW_ZOOM_FACTOR, FILL_PREVIEW_VIEWPORT, PREVIEW_VIEWPORT_MAX_AREA, @@ -35,6 +34,7 @@ import { type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; +import { Link } from "@tanstack/react-router"; import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; @@ -84,7 +84,6 @@ import { persistClientSettingsUpdate, useClientSettings, useClientSettingsHydrated, - usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; @@ -550,39 +549,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) } function AgentBrowserAccessSetting() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - return ( - updateSettings({ - enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, - }) - } - /> - ) : null - } + description="Choose whether agents can use the preview browser for all projects or a specific project." control={ - - updateSettings({ enableAgentBrowserAccess: Boolean(checked) }) + } /> ); diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx new file mode 100644 index 000000000000..1794a5fdaa2e --- /dev/null +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -0,0 +1,69 @@ +import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { SettingsIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../../keybindings"; +import { commandForProjectScript } from "../../projectScripts"; +import { ScriptIcon } from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { SettingsRow } from "./settingsLayout"; + +export function ProjectActionsList({ + scripts, + keybindings, + disabled, + onEdit, +}: { + scripts: readonly ProjectScript[]; + keybindings: ResolvedKeybindingsConfig; + disabled: boolean; + onEdit: (script: ProjectScript) => void; +}) { + if (scripts.length === 0) + return ( +

+ No actions configured. +

+ ); + return scripts.map((script) => { + const shortcutLabel = shortcutLabelForCommand(keybindings, commandForProjectScript(script.id)); + return ( + + + {script.name} + {script.runOnWorktreeCreate ? ( + + setup + + ) : null} + {script.previewUrl ? ( + + preview · desktop only + + ) : null} + + } + description={{script.command}} + control={ + <> + {shortcutLabel ? ( + {shortcutLabel} + ) : null} + + + } + /> + ); + }); +} diff --git a/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx new file mode 100644 index 000000000000..7371c7bcd4fa --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx @@ -0,0 +1,234 @@ +import type { EnvironmentId, ProjectScript } from "@t3tools/contracts"; +import { + mapAtomCommandResult, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { PlusIcon } from "lucide-react"; +import { useRef, useState } from "react"; +import { isElectron } from "../../env"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "../../lib/projectScriptKeybindings"; +import { + buildProjectScript, + commandForProjectScript, + nextProjectScriptId, +} from "../../projectScripts"; +import { useEnvironments } from "../../state/environments"; +import { useProjects } from "../../state/entities"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + type NewProjectScriptInput, + type ProjectScriptEditorRequest, +} from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +export function ProjectDefaultActionsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const projects = useProjects(); + const targets = environments.filter( + (environment) => + (environmentId === null || environment.environmentId === environmentId) && + environment.connection.phase === "connected" && + environment.serverConfig !== null, + ); + const representative = targets[0]?.serverConfig; + const scripts = representative?.settings.defaultProjectScripts ?? []; + const keybindings = representative?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const mixed = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultProjectScripts) !== + JSON.stringify(scripts), + ); + const [request, setRequest] = useState(null); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "default actions update"); + const upsertKeybinding = useAtomCommand( + serverEnvironment.upsertKeybinding, + "default action shortcut update", + ); + const removeKeybinding = useAtomCommand( + serverEnvironment.removeKeybinding, + "default action shortcut removal", + ); + + async function persist( + transform: (current: readonly ProjectScript[]) => readonly ProjectScript[], + scriptId?: string, + keybinding?: string | null, + ): Promise> { + if (savingRef.current || targets.length === 0) + return AsyncResult.failure( + Cause.fail(new Error("No available machine, or another action change is saving.")), + ); + savingRef.current = true; + setSaving(true); + try { + for (const target of targets) { + const config = target.serverConfig; + if (!config) continue; + const nextScripts = transform(config.settings.defaultProjectScripts); + const result = await updateSettings({ + environmentId: target.environmentId, + input: { + patch: { + defaultProjectScripts: nextScripts, + }, + }, + }); + if (result._tag === "Failure") return mapAtomCommandResult(result, () => undefined); + if (!isElectron || !scriptId) continue; + const command = commandForProjectScript(scriptId); + const previousValue = keybindingValueForCommand(config.keybindings, command); + const previous = previousValue + ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) + : null; + const next = decodeProjectScriptKeybindingRule({ keybinding, command }); + if (next) { + const bindingResult = await upsertKeybinding({ + environmentId: target.environmentId, + input: previous && previous.key !== next.key ? { ...next, replace: previous } : next, + }); + if (bindingResult._tag === "Failure") + return mapAtomCommandResult(bindingResult, () => undefined); + } else if ( + previous && + !( + !nextScripts.some((script) => script.id === scriptId) && + (Object.values(config.settings.projectScriptOverrides).some((scripts) => + scripts?.some((script) => script.id === scriptId), + ) || + projects.some( + (project) => + project.environmentId === target.environmentId && + project.scripts.some((script) => script.id === scriptId), + )) + ) + ) { + const bindingResult = await removeKeybinding({ + environmentId: target.environmentId, + input: previous, + }); + if (bindingResult._tag === "Failure") + return mapAtomCommandResult(bindingResult, () => undefined); + } + } + return AsyncResult.success(undefined); + } finally { + savingRef.current = false; + setSaving(false); + } + } + + async function submit(scriptId: string | null, input: NewProjectScriptInput) { + const existingIds = [ + ...projects.flatMap((project) => project.scripts.map((script) => script.id)), + ...targets.flatMap((target) => { + const settings = target.serverConfig?.settings; + return settings + ? [ + ...settings.defaultProjectScripts, + ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), + ].map((script) => script.id) + : []; + }), + ]; + const id = scriptId ?? nextProjectScriptId(input.name, existingIds); + const next = buildProjectScript(id, input); + return mapAtomCommandResult( + await 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, + ), + () => undefined, + ); + } + + return ( + + + Import scripts + + } + /> + (target.serverConfig?.settings.defaultProjectScripts.length ?? 0) > 0, + ) ? ( + void persist(() => [])} + /> + ) : null + } + control={ + + } + /> + {mixed ? ( + + ) : ( + setRequest(editorRequestForScript(script, keybindings))} + /> + )} + + void persist((current) => current.filter((script) => script.id !== id), id, null) + } + onClose={() => setRequest(null)} + /> + + ); +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx new file mode 100644 index 000000000000..a3bd255dcfaa --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -0,0 +1,461 @@ +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + type ModelSelection, + type ProviderInstanceId, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { useNavigate } from "@tanstack/react-router"; +import { useRef, useState } from "react"; +import { Trash2Icon } from "lucide-react"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { toastManager } from "../ui/toast"; +import { Switch } from "../ui/switch"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; +import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; +import { searchableSetting } from "./settingsSearch"; +import { + SETTINGS_PICKER_TRIGGER_CLASSNAME, + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; + +/** Defaults are written only to the machines selected on the projects settings page. */ +export function ProjectDefaultsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clientSettings = useClientSettings(); + const updateClientSettings = useUpdateClientSettings(); + const navigate = useNavigate(); + const updateSettings = useAtomCommand( + serverEnvironment.updateSettings, + "project defaults update", + ); + const savingRef = useRef(false); + const [saving, setSaving] = useState(false); + const scoped = environments.filter( + (environment) => environmentId === null || environment.environmentId === environmentId, + ); + const targets = scoped.filter( + (environment) => + environment.connection.phase === "connected" && environment.serverConfig !== null, + ); + const representative = + targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0]; + const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; + const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; + const settings = { ...serverSettings, ...clientSettings }; + const storedSelection = serverSettings.defaultModelSelection; + const selection = resolveDefaultProviderModelSelection(providers, storedSelection); + const entries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), + ); + const modelOptions = getCustomModelOptionsByInstance( + settings, + providers, + selection?.instanceId, + selection?.model, + ); + const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); + const mixedModel = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !== + JSON.stringify(storedSelection), + ); + const mixedWorkspace = targets.some( + (target) => + target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode, + ); + const mixedBrowser = targets.some( + (target) => + target.serverConfig?.settings.enableAgentBrowserAccess !== + serverSettings.enableAgentBrowserAccess, + ); + const disabled = saving || targets.length === 0; + const mixedAutoPull = targets.some( + (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull, + ); + + function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { + const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); + for (const target of targets) { + const config = target.serverConfig; + if (!config) continue; + const entry = applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === instanceId); + const options = getCustomModelOptionsByInstance( + { ...config.settings, ...clientSettings }, + 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 ${target.label}. Select that machine to choose its default separately.`; + } + } + return null; + } + + async function save(patch: ServerSettingsPatch) { + if (disabled || savingRef.current) return; + const nextModel = patch.defaultModelSelection; + const reason = nextModel ? modelDisabledReason(nextModel.instanceId, nextModel.model) : null; + if (reason) { + toastManager.add({ type: "error", title: "Default model not saved", description: reason }); + return; + } + savingRef.current = true; + setSaving(true); + try { + await Promise.all( + targets.map((target) => + updateSettings({ environmentId: target.environmentId, input: { patch } }), + ), + ); + } finally { + savingRef.current = false; + setSaving(false); + } + } + + const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value }); + return ( + + + + } + /> + + + +
+ } + /> + {scoped.length > targets.length || targets.length === 0 ? ( +

+ {targets.length === 0 + ? "Connect a machine to change its project defaults." + : "Changes apply to connected machines only. Offline machines keep their current defaults."} +

+ ) : null} + setModel(null)} + /> + ) : null + } + control={ + selection && activeEntry ? ( +
+ { + if (representative) + void navigate({ + to: "/settings/providers", + search: { environmentId: representative.environmentId, instanceId }, + }); + }} + onInstanceModelChange={(instanceId, model) => + setModel(createModelSelection(instanceId, model)) + } + /> + {!mixedModel ? ( + {}} + modelOptions={selection.options ?? []} + allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} + triggerVariant="outline" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + onModelOptionsChange={(options) => + setModel(createModelSelection(selection.instanceId, selection.model, options)) + } + /> + ) : null} +
+ ) : ( + No providers available + ) + } + /> + + void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode }) + } + /> + ) : null + } + control={ + + } + /> + void save({ defaultAutoPull: false })} + /> + ) : null + } + control={ + void save({ defaultAutoPull: enabled })} + /> + } + /> + + void save({ + enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess, + }) + } + /> + ) : null + } + control={ + + } + /> +
+ + + + + + + } + /> + + void updateClientSettings({ + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + Remove checkout + + } + /> + + + + + + Remove project + + } + /> + +
+ ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 9263ff6bd552..f2df86efea5a 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -13,8 +13,7 @@ import { selectProjectGroupingSettings, } from "../../logicalProject"; import { - resolveEnvironmentMachineKind, - type ContextMenuItem, + type EnvironmentId, type ModelSelection, type ProjectIconOverride, type ProviderDriverKind, @@ -24,27 +23,17 @@ import { } 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 { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; -import { - ChevronDownIcon, - CopyIcon, - Layers3Icon, - PlusIcon, - SettingsIcon, - Trash2Icon, -} from "lucide-react"; -import { - lazy, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MouseEvent as ReactMouseEvent, -} from "react"; +import * as Equal from "effect/Equal"; +import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; import { isElectron } from "../../env"; @@ -52,11 +41,9 @@ import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, - usePrimarySettings, } from "../../hooks/useSettings"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; -import { shortcutLabelForCommand } from "../../keybindings"; +import { ProjectActionsList } from "./ProjectActionsList"; import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; import { readLocalApi } from "../../localApi"; @@ -85,7 +72,6 @@ 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 { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { ProjectFavicon } from "../ProjectFavicon"; import { EMPTY_PROJECT_SCRIPT_INPUT, @@ -107,17 +93,8 @@ import { MenuTrigger, } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { ScrollArea } from "../ui/scroll-area"; -import { SidebarInset } from "../ui/sidebar"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; -import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, @@ -130,7 +107,6 @@ import { ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; -import { providerSettingsTabClassName } from "./providerSettingsTabs"; const ProjectIconPickerDialog = lazy(() => import("./ProjectIconPickerDialog").then((module) => ({ @@ -173,98 +149,13 @@ function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } -export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { - const navigate = useNavigate(); - const canGoBack = useCanGoBack(); - const navigateBackWithinApp = useCallback(() => { - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, navigate]); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (event.key !== "Escape") return; - event.preventDefault(); - const activeElement = document.activeElement; - if (activeElement instanceof HTMLElement) { - activeElement.blur(); - } - navigateBackWithinApp(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [navigateBackWithinApp]); - - return ( - -
- - - - -
-
- ); -} - -function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { - const groups = useSettingsProjectGroups(); - const navigate = useNavigate(); - const selected = groups.find((group) => group.projectKey === projectKey) ?? null; - const openProjectMenu = (event: ReactMouseEvent) => { - const api = readLocalApi(); - if (!api) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const items: ContextMenuItem[] = groups.map((group) => ({ - id: group.projectKey, - label: group.displayName, - })); - void settlePromise(() => - api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), - ).then((clicked) => { - if (clicked._tag === "Failure" || clicked.value === null) return; - void navigate({ - to: "/projects/$projectKey", - params: { projectKey: clicked.value }, - replace: true, - hashScrollIntoView: false, - }); - }); - }; - - return ( - - Projects - - - {selected ? ( - - ) : ( - Unavailable project - )} - - - ); -} - -export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { +export function ProjectSettingsPanel({ + projectKey, + environmentId = null, +}: { + projectKey: string; + environmentId?: EnvironmentId | null; +}) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); @@ -292,13 +183,13 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { ); if (successor) { void navigate({ - to: "/projects/$projectKey", - params: { projectKey: successor.projectKey }, + to: "/settings/projects", + search: { project: successor.projectKey, machine: environmentId ?? undefined }, replace: true, hashScrollIntoView: false, }); } - }, [groups, navigate, projectKey, selected]); + }, [groups, navigate, projectKey, selected, environmentId]); if (!selected) { return ( @@ -309,7 +200,25 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { ); } - return ; + const members = + environmentId === null + ? selected.memberProjects + : selected.memberProjects.filter((member) => member.environmentId === environmentId); + if (members.length === 0) + return ( +

+ This project has no checkout on this machine. +

+ ); + const scopedGroup = { + ...selected, + memberProjects: members, + environmentId: members[0]!.environmentId, + id: members[0]!.id, + }; + return ( + + ); } function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { @@ -324,7 +233,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { group.memberProjects.find( (member) => member.environmentId === group.environmentId && member.id === group.id, ) ?? group.memberProjects[0]!; - const settings = usePrimarySettings(); // 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. @@ -336,6 +244,65 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { 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 browserMixed = browserOverrides.some((value) => value !== browserOverrides[0]); + const browserOverride = browserOverrides[0]; + const setBooleanOverride = async ( + key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", + enabled: boolean | undefined, + ) => { + if (savingBrowserAccessRef.current) return; + savingBrowserAccessRef.current = true; + setSavingBrowserAccess(true); + try { + if (key === "projectAutoPullOverrides" && enabled === undefined) { + const result = await updateAllMembers( + { autoPull: false }, + "Failed to reset automatic pull", + ); + if (result._tag === "Failure") return; + } + for (const environmentId of new Set( + group.memberProjects.map((member) => member.environmentId), + )) { + 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 overrides = { + ...environment.serverConfig.settings[key], + }; + for (const member of group.memberProjects.filter( + (member) => member.environmentId === environmentId, + )) { + if (enabled === undefined) delete overrides[member.id]; + else overrides[member.id] = enabled; + } + const result = await updateServerSettings({ + environmentId, + input: { patch: { [key]: overrides } }, + }); + if (result._tag === "Failure") return; + } + } finally { + savingBrowserAccessRef.current = false; + setSavingBrowserAccess(false); + } + }; + const setBrowserAccess = (enabled: boolean | undefined) => + setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, @@ -344,20 +311,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { reportFailure: false, }); const projectNameEditedRef = useRef(false); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ - onCopy: ({ path }) => { - toastManager.add({ type: "success", title: "Path copied", description: path }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; @@ -371,14 +324,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ? window.desktopBridge?.pickProjectFavicon : undefined; - const threadCountByMember = useMemo(() => { - const counts = new Map(); - for (const thread of threads) { - const key = `${thread.environmentId}:${thread.projectId}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return counts; - }, [threads]); const reportFailure = useCallback((title: string, result: AtomCommandResult) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -453,7 +398,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { // ----- default model ----- const storedSelection = representative.defaultModelSelection; - const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); + const mixedModel = group.memberProjects.some( + (member) => !Equal.equals(member.defaultModelSelection, storedSelection), + ); + const resolvedSelection = resolveDefaultProviderModelSelection( + serverProviders, + storedSelection ?? projectSettings.defaultModelSelection, + ); const resolvedInstanceId = resolvedSelection?.instanceId ?? null; const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( @@ -477,14 +428,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); - const setDefaultModel = useCallback( - (selection: ModelSelection | null) => - void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), - [updateAllMembers], - ); + 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( @@ -494,12 +476,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); - const autoPull = representative.autoPull ?? false; - const setAutoPull = useCallback( - (enabled: boolean) => - void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), - [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); @@ -528,12 +522,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { (member) => member.physicalProjectKey === selectedCheckoutKey, ); const selectedCheckout = selectedCheckoutMatch ?? representative; - const allMachinesSelected = hasMultipleCheckouts && selectedCheckoutMatch === undefined; const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const scripts = selectedCheckout.scripts; + const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId); + const scripts = resolveProjectScripts(scriptSettings, selectedCheckout); + const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout); const [editorRequest, setEditorRequest] = useState(null); // Script writes replace the whole array, so two overlapping writes computed // from the same snapshot would drop each other's changes. One at a time. @@ -545,7 +540,8 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); // 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 ?? settings.defaultThreadEnvMode; + const inheritedEnvMode = + t3File.file?.defaultThreadEnvMode ?? projectSettings.defaultThreadEnvMode; const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; const importableScripts = useMemo( () => @@ -562,9 +558,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const persistScripts = useCallback( async ( - nextScripts: ReadonlyArray>, - keybinding: string | null | undefined, - keybindingCommand: ReturnType, + nextScripts: ReadonlyArray> | null, + keybinding?: string | null, + keybindingCommand?: ReturnType, ): Promise> => { if (savingScriptsRef.current) { return AsyncResult.failure( @@ -576,11 +572,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { try { // Captured before the write so a cleared or deleted binding can be // removed from the keybindings config afterwards. - const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); + const previousKeybinding = keybindingCommand + ? keybindingValueForCommand(keybindings, keybindingCommand) + : null; const updateResult = mapAtomCommandResult( - await updateProject({ + await updateServerSettings({ environmentId: selectedCheckout.environmentId, - input: { projectId: selectedCheckout.id, scripts: nextScripts }, + input: { + patch: { + projectScriptOverrides: { + ...scriptSettings.projectScriptOverrides, + [selectedCheckout.id]: nextScripts, + }, + }, + }, }), () => undefined, ); @@ -589,6 +594,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { return updateResult; } + if (!keybindingCommand) return updateResult; const keybindingRule = decodeProjectScriptKeybindingRule({ keybinding, command: keybindingCommand, @@ -618,7 +624,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { return result; } } - } else if (previousTarget) { + } else if ( + previousTarget && + !( + scriptSettings.defaultProjectScripts.some( + (script) => commandForProjectScript(script.id) === keybindingCommand, + ) && + !nextScripts?.some((script) => commandForProjectScript(script.id) === keybindingCommand) + ) + ) { for (const environmentId of environmentIds) { const result = mapAtomCommandResult( await removeKeybinding({ environmentId, input: previousTarget }), @@ -642,7 +656,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { reportFailure, selectedCheckout.environmentId, selectedCheckout.id, - updateProject, + scriptSettings.projectScriptOverrides, + scriptSettings.defaultProjectScripts, + updateServerSettings, upsertKeybinding, ], ); @@ -711,7 +727,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }); } }, - [submitScript], + [submitScript, setEditorRequest], ); // ----- checkouts ----- @@ -818,7 +834,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ], ); - const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0; const selectedCheckoutGrouping = projectGroupingSettings.sidebarProjectGroupingOverrides?.[ deriveProjectGroupingOverrideKey(selectedCheckout) @@ -827,61 +842,8 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { return ( <> - - {hasMultipleCheckouts ? ( - -
- - {group.memberProjects.map((member) => { - const label = member.environmentLabel ?? "This machine"; - const selected = - !allMachinesSelected && - member.physicalProjectKey === selectedCheckout.physicalProjectKey; - const machine = resolveEnvironmentMachineKind( - environmentById.get(member.environmentId)?.serverConfig ?? null, - ); - return ( - - ); - })} -
-
- ) : null} - - + + member.defaultModelSelection !== null) ? ( setDefaultModel(null)} /> ) : null @@ -1018,11 +992,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { /> member.defaultThreadEnvMode !== null) ? ( setDefaultThreadEnvMode(null)} /> ) : null @@ -1062,59 +1048,128 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { setAutoPull(false)} /> + autoPullOverridden ? ( + void setAutoPull(undefined)} + /> ) : null } control={ void setAutoPull(enabled)} /> } /> + value !== undefined) ? ( + void setBrowserAccess(undefined)} + /> + ) : null + } + control={ + + } + /> - -
-
- - - copyPathToClipboard(selectedCheckout.workspaceRoot, { - path: selectedCheckout.workspaceRoot, - }) - } - > - - {selectedCheckout.workspaceRoot} - - - - } - /> - Copy path - -
- {selectedCheckoutThreadCount === 1 - ? "1 thread" - : `${selectedCheckoutThreadCount} threads`} -
-
-
+ + {hasMultipleCheckouts ? ( + { + if (value) setSelectedCheckoutKey(value); + }} + > + + {selectedCheckoutLabel} + + + {group.memberProjects.map((member) => ( + + {member.environmentLabel ?? "This machine"} + + ))} + + + } + /> + ) : null} updateGroupingPreference(selectedCheckout, "inherit")} + /> + ) : null + } control={ { + if (next) onChange(next === "all" ? null : next); + }} + > + + + {value === null ? allIcon : selected?.icon} + + {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} + + + + + + + {allIcon}All {label}s + + + {options.map((option) => ( + + + {option.icon} + {option.label} + + + ))} + + + ); +} + +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: ( + + ), + })); + 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 ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b0344b640393..77293b34373b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2555,18 +2555,32 @@ export function GeneralSettingsPanel() { + } + size="sm" + variant="outline" + > + Project settings + + } + /> + + updateSettings({ - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, }) @@ -2575,62 +2589,15 @@ export function GeneralSettingsPanel() { ) : null } control={ - + + updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) + } + aria-label="Start new worktrees from origin by default" + /> } /> - - {settings.defaultThreadEnvMode === "worktree" ? ( - - updateSettings({ - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } - control={ - - updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) - } - aria-label="Start new worktrees from origin by default" - /> - } - /> - ) : null} = { "/settings/general": Settings2Icon, "/settings/appearance": PaletteIcon, + "/settings/projects": PanelsTopLeftIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, "/settings/integrations": BlocksIcon, diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 82fbed96459f..1bfa8c87146e 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -284,7 +284,11 @@ export function SettingsRow({ ref={targetRef} tabIndex={rowProps.id ? -1 : rowProps.tabIndex} data-slot="settings-row" - className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)} + className={cn( + "rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground", + children ? "pt-3 pb-1" : "py-3", + className, + )} >
@@ -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 22e2204d8a27..ba27e15784c3 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", @@ -233,14 +243,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"], }, { @@ -343,7 +352,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..c2e78fc4cc91 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -49,14 +49,29 @@ const testState = vi.hoisted(() => { }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }), + useAtomValue: () => + 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 +128,7 @@ vi.mock("../state/entities", () => ({ useProjects: () => [], useThread: () => null, })); -vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} })); +vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} })); 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..e831fba214ef 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 } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -55,12 +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 primaryServerSettings = useAtomValue(primaryServerSettingsAtom); + const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { @@ -83,6 +83,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 +140,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 +160,7 @@ export function useNewThreadHandler() { project.workspaceRoot, ) : null, - globalDefault: primaryServerSettings.defaultThreadEnvMode, + globalDefault: targetServerSettings.defaultThreadEnvMode, }); }; const logicalProjectKey = project @@ -254,7 +257,7 @@ export function useNewThreadHandler() { envMode: defaultEnvMode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: defaultEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: targetServerSettings.newWorktreesStartFromOrigin, }), }; } @@ -409,7 +412,7 @@ export function useNewThreadHandler() { options?.startFromOrigin ?? resolveNewDraftStartFromOrigin({ envMode: initialEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: targetServerSettings.newWorktreesStartFromOrigin, }), runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), @@ -429,7 +432,7 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router], + [environmentServerConfigs, getCurrentRouteTarget, projectGroupingSettings, router], ); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f7c47ace6840..c0f8ef4d7682 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,6 +17,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' @@ -69,6 +70,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', @@ -147,6 +153,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 @@ -167,6 +174,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 @@ -190,6 +198,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 @@ -214,6 +223,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -234,6 +244,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/' @@ -256,6 +267,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -331,6 +343,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' @@ -442,6 +461,7 @@ interface SettingsRouteChildren { SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -454,6 +474,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 561d45255167..c76c18544df2 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -1,15 +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**. -When a project group has more than one checkout, select **All machines** to edit its shared name, -icon, model, workspace, and pull behavior. Select a machine tab to view that checkout's path, edit -its grouping and actions, or remove it. +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/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fef3b2992f07..6660975987bd 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 { @@ -10,7 +15,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, @@ -833,6 +838,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)), ), @@ -1088,6 +1109,14 @@ export const ServerSettingsPatch = Schema.Struct({ enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + projectAgentBrowserAccessOverrides: Schema.optionalKey(Schema.Record(ProjectId, 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.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 64b0e1b8337b..b8bad45a16fa 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,6 +10,7 @@ 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, extractPersistedServerObservabilitySettings, @@ -16,9 +18,102 @@ import { normalizePersistedServerSettingString, 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("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: {} }); + 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: {} }); + 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("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("normalizes optional persisted strings", () => { expect(normalizePersistedServerSettingString(undefined)).toBeUndefined(); expect(normalizePersistedServerSettingString(" ")).toBeUndefined(); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 67f73f84063a..217b654567e4 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 = ( @@ -206,6 +228,21 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(patch.projectAgentBrowserAccessOverrides !== undefined + ? { projectAgentBrowserAccessOverrides: patch.projectAgentBrowserAccessOverrides } + : {}), + ...(patch.projectAutoPullOverrides !== undefined + ? { projectAutoPullOverrides: patch.projectAutoPullOverrides } + : {}), + ...(patch.defaultModelSelection !== undefined + ? { defaultModelSelection: patch.defaultModelSelection } + : {}), + ...(patch.defaultProjectScripts !== undefined + ? { defaultProjectScripts: patch.defaultProjectScripts } + : {}), + ...(patch.projectScriptOverrides !== undefined + ? { projectScriptOverrides: patch.projectScriptOverrides } + : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeUsageLimitSources( From 06f43fa14bac3d94cc875c7960651d9d158fdf3e Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:20:16 +0000 Subject: [PATCH 05/11] fix(settings): preserve inherited defaults across machines --- apps/server/src/serverRuntimeStartup.ts | 6 ++-- apps/web/src/components/ChatView.tsx | 8 +++-- .../settings/ProjectSettingsPanel.tsx | 28 ++++++++++++---- apps/web/src/hooks/useHandleNewThread.test.ts | 33 +++++++++++-------- apps/web/src/hooks/useHandleNewThread.ts | 15 ++++++--- 5 files changed, 61 insertions(+), 29 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index c745578e3ecd..15db5c1d9fc0 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -711,8 +711,10 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, - settings: Pick = - DEFAULT_SERVER_SETTINGS, + settings: Pick< + typeof DEFAULT_SERVER_SETTINGS, + "defaultAutoPull" | "projectAutoPullOverrides" + > = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9944dd108353..f004ded00c2a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -276,6 +276,7 @@ import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, + primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; @@ -1468,6 +1469,7 @@ 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, ); @@ -4959,7 +4961,7 @@ export default function ChatView(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - settings.newWorktreesStartFromOrigin) + primaryServerSettings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -7460,7 +7462,7 @@ export default function ChatView(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -7472,7 +7474,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - settings.newWorktreesStartFromOrigin, + primaryServerSettings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index f2df86efea5a..6b0e42846342 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -252,8 +252,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { environmentById.get(member.environmentId)?.serverConfig?.settings .projectAgentBrowserAccessOverrides[member.id], ); - const browserMixed = browserOverrides.some((value) => value !== browserOverrides[0]); const browserOverride = browserOverrides[0]; + const browserMixed = group.memberProjects.some((member, index) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + return ( + browserOverrides[index] !== browserOverride || + (browserOverrides[index] ?? settings?.enableAgentBrowserAccess) !== + (browserOverride ?? projectSettings.enableAgentBrowserAccess) + ); + }); const setBooleanOverride = async ( key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", enabled: boolean | undefined, @@ -398,13 +405,22 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { // ----- default model ----- const storedSelection = representative.defaultModelSelection; - const mixedModel = group.memberProjects.some( - (member) => !Equal.equals(member.defaultModelSelection, storedSelection), - ); 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) || + JSON.stringify( + resolveDefaultProviderModelSelection( + config?.providers ?? EMPTY_SERVER_PROVIDERS, + member.defaultModelSelection ?? config?.settings.defaultModelSelection ?? null, + ), + ) !== JSON.stringify(resolvedSelection) + ); + }); const resolvedInstanceId = resolvedSelection?.instanceId ?? null; const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( @@ -923,7 +939,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { title="Model" status={ mixedModel - ? "Mixed overrides. Choosing a model updates all selected checkouts." + ? "Mixed defaults or overrides. Choosing a model updates all selected checkouts." : storedSelection === null ? "Inherited" : "Overridden" @@ -1078,7 +1094,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { title="Agent browser access" description={ browserMixed - ? "Mixed overrides across selected checkouts." + ? "Mixed defaults or overrides across selected checkouts." : browserOverride === undefined ? "Inherited from machine defaults. Controls agent access to the preview browser." : "Overridden for this project. Applies when the agent session next starts." diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index c2e78fc4cc91..afd503e63c25 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -49,19 +49,21 @@ const testState = vi.hoisted(() => { }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => - new Map([ - [ - "environment-ssh", - { - settings: { - defaultThreadEnvMode: "local", - newWorktreesStartFromOrigin: false, - defaultModelSelection: null, - }, - }, - ], - ]), + 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", @@ -128,7 +130,10 @@ vi.mock("../state/entities", () => ({ useProjects: () => [], useThread: () => null, })); -vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} })); +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 e831fba214ef..78dfc1b13fe6 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -35,7 +35,7 @@ import { resolveNewThreadModelSelectionOverride, } from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; -import { environmentServerConfigsAtom } from "../state/server"; +import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -61,6 +61,7 @@ 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(() => { @@ -257,7 +258,7 @@ export function useNewThreadHandler() { envMode: defaultEnvMode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: defaultEnvMode, - newWorktreesStartFromOrigin: targetServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), }; } @@ -412,7 +413,7 @@ export function useNewThreadHandler() { options?.startFromOrigin ?? resolveNewDraftStartFromOrigin({ envMode: initialEnvMode, - newWorktreesStartFromOrigin: targetServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), @@ -432,7 +433,13 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [environmentServerConfigs, getCurrentRouteTarget, projectGroupingSettings, router], + [ + environmentServerConfigs, + getCurrentRouteTarget, + primaryServerSettings.newWorktreesStartFromOrigin, + projectGroupingSettings, + router, + ], ); } From 9c738e3978528d58d897948689b313536132cd04 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:31:38 +0000 Subject: [PATCH 06/11] fix(settings): address scoped defaults review findings --- .../project/ProjectSetupScriptRunner.test.ts | 32 ++++--- .../src/project/ProjectSetupScriptRunner.ts | 28 +++--- .../provider/Layers/ProviderService.test.ts | 4 +- apps/web/src/components/ChatView.tsx | 3 +- .../ProjectDefaultActionsSettings.tsx | 75 ++++++++------- .../settings/ProjectSettingsPanel.tsx | 96 ++++++++++++------- .../components/settings/ProjectsSettings.tsx | 5 +- packages/shared/src/serverSettings.test.ts | 38 ++++++++ packages/shared/src/serverSettings.ts | 7 +- 9 files changed, 186 insertions(+), 102 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 108db2a07d64..2551b39e5a85 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -6,9 +6,9 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; -import { layerTest as settingsLayerTest } from "../serverSettings.ts"; const isProjectSetupScriptOperationError = Schema.is( ProjectSetupScriptRunner.ProjectSetupScriptOperationError, @@ -68,10 +68,12 @@ const makeTerminalManagerLayer = ( const testLayer = ( project: OrchestrationProject, terminal: Pick, + settings = ServerSettings.layerTest(), ) => ProjectSetupScriptRunner.layer.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge(makeTerminalManagerLayer(terminal)), + Layer.provide(settings), ); describe("ProjectSetupScriptRunner", () => { @@ -114,20 +116,20 @@ describe("ProjectSetupScriptRunner", () => { }); }).pipe( Effect.provide( - testLayer(makeProject([]), { open, write }).pipe( - Layer.provide( - settingsLayerTest({ - defaultProjectScripts: [ - { - id: "default-setup", - name: "Setup", - command: "npm install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ], - }), - ), + testLayer( + makeProject([]), + { open, write }, + ServerSettings.layerTest({ + defaultProjectScripts: [ + { + id: "default-setup", + name: "Setup", + command: "npm install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }), ), ), ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 9392c10e6e97..6a79c853dc99 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,4 +1,4 @@ -import { DEFAULT_SERVER_SETTINGS, ProjectId } from "@t3tools/contracts"; +import { ProjectId } from "@t3tools/contracts"; import { projectScriptRuntimeEnv, resolveProjectScripts, @@ -11,8 +11,8 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; export interface ProjectSetupScriptRunnerResultNoScript { readonly status: "no-script"; @@ -86,7 +86,7 @@ export class ProjectSetupScriptRunner extends Context.Service< export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager.TerminalManager; - const serverSettings = yield* Effect.serviceOption(ServerSettingsService); + const serverSettings = yield* ServerSettings.ServerSettingsService; const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", @@ -130,18 +130,16 @@ export const make = Effect.gen(function* () { return yield* new ProjectSetupScriptProjectNotFoundError(errorContext); } - const settings = Option.isSome(serverSettings) - ? yield* serverSettings.value.getSettings.pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "readSettings", - cause, - }), - ), - ) - : DEFAULT_SERVER_SETTINGS; + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readSettings", + cause, + }), + ), + ); const script = setupProjectScript(resolveProjectScripts(settings, project)); if (!script) { return { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ef5c14a1a472..365eb8eeb693 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4304,6 +4304,8 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { ); }); +const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); + describe("agent browser access", () => { const revokedThreads: Array = []; const projectId = ProjectId.make("project-browser-access"); @@ -4345,7 +4347,7 @@ describe("agent browser access", () => { Effect.gen(function* () { assert.equal(requestedThreadId, threadId); return Option.some( - yield* Schema.decodeUnknownEffect(OrchestrationThreadShell)({ + yield* decodeBrowserAccessThreadShell({ id: threadId, projectId, title: "Browser access test", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f004ded00c2a..70c1e1862228 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3544,7 +3544,6 @@ export default function ChatView(props: ChatViewProps) { input: { patch: { projectScriptOverrides: { - ...settings.projectScriptOverrides, [input.projectId]: input.nextScripts, }, }, @@ -3572,7 +3571,7 @@ export default function ChatView(props: ChatViewProps) { } return updateResult; }, - [environmentId, settings.projectScriptOverrides, updateProjectScriptSettings, upsertKeybinding], + [environmentId, updateProjectScriptSettings, upsertKeybinding], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { diff --git a/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx index 7371c7bcd4fa..2cdf1971822e 100644 --- a/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx @@ -92,40 +92,47 @@ export function ProjectDefaultActionsSettings({ }, }); if (result._tag === "Failure") return mapAtomCommandResult(result, () => undefined); - if (!isElectron || !scriptId) continue; - const command = commandForProjectScript(scriptId); - const previousValue = keybindingValueForCommand(config.keybindings, command); - const previous = previousValue - ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) - : null; - const next = decodeProjectScriptKeybindingRule({ keybinding, command }); - if (next) { - const bindingResult = await upsertKeybinding({ - environmentId: target.environmentId, - input: previous && previous.key !== next.key ? { ...next, replace: previous } : next, - }); - if (bindingResult._tag === "Failure") - return mapAtomCommandResult(bindingResult, () => undefined); - } else if ( - previous && - !( - !nextScripts.some((script) => script.id === scriptId) && - (Object.values(config.settings.projectScriptOverrides).some((scripts) => - scripts?.some((script) => script.id === scriptId), - ) || - projects.some( - (project) => - project.environmentId === target.environmentId && - project.scripts.some((script) => script.id === scriptId), - )) - ) - ) { - const bindingResult = await removeKeybinding({ - environmentId: target.environmentId, - input: previous, - }); - if (bindingResult._tag === "Failure") - return mapAtomCommandResult(bindingResult, () => undefined); + if (!isElectron) continue; + const changedScriptIds = scriptId + ? [scriptId] + : config.settings.defaultProjectScripts + .filter((script) => !nextScripts.some((nextScript) => nextScript.id === script.id)) + .map((script) => script.id); + for (const changedScriptId of changedScriptIds) { + const command = commandForProjectScript(changedScriptId); + const previousValue = keybindingValueForCommand(config.keybindings, command); + const previous = previousValue + ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) + : null; + const next = decodeProjectScriptKeybindingRule({ keybinding, command }); + if (next) { + const bindingResult = await upsertKeybinding({ + environmentId: target.environmentId, + input: previous && previous.key !== next.key ? { ...next, replace: previous } : next, + }); + if (bindingResult._tag === "Failure") + return mapAtomCommandResult(bindingResult, () => undefined); + } else if ( + previous && + !( + !nextScripts.some((script) => script.id === changedScriptId) && + (Object.values(config.settings.projectScriptOverrides).some((scripts) => + scripts?.some((script) => script.id === changedScriptId), + ) || + projects.some( + (project) => + project.environmentId === target.environmentId && + project.scripts.some((script) => script.id === changedScriptId), + )) + ) + ) { + const bindingResult = await removeKeybinding({ + environmentId: target.environmentId, + input: previous, + }); + if (bindingResult._tag === "Failure") + return mapAtomCommandResult(bindingResult, () => undefined); + } } } return AsyncResult.success(undefined); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 6b0e42846342..3db3ce628a5d 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -160,24 +160,36 @@ export function ProjectSettingsPanel({ const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; + const members = useMemo( + () => + selected?.memberProjects.filter( + (member) => environmentId === null || member.environmentId === environmentId, + ) ?? [], + [selected, environmentId], + ); // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. - const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null); + const lastSelectionRef = useRef<{ + key: string; + environmentId: EnvironmentId | null; + memberKeys: string[]; + } | null>(null); useEffect(() => { - if (!selected) return; + if (!selected || members.length === 0) return; lastSelectionRef.current = { key: selected.projectKey, - memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey), + environmentId, + memberKeys: members.map((member) => member.physicalProjectKey), }; - }, [selected]); + }, [selected, members, environmentId]); // 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 (selected !== null) return; + if (members.length > 0) return; const last = lastSelectionRef.current; - if (last?.key !== projectKey) return; + if (last?.key !== projectKey || last.environmentId !== environmentId) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); @@ -189,7 +201,7 @@ export function ProjectSettingsPanel({ hashScrollIntoView: false, }); } - }, [groups, navigate, projectKey, selected, environmentId]); + }, [groups, navigate, projectKey, members.length, environmentId]); if (!selected) { return ( @@ -200,10 +212,6 @@ export function ProjectSettingsPanel({
); } - const members = - environmentId === null - ? selected.memberProjects - : selected.memberProjects.filter((member) => member.environmentId === environmentId); if (members.length === 0) return (

@@ -217,11 +225,21 @@ export function ProjectSettingsPanel({ id: members[0]!.id, }; return ( - + ); } -function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { +function ProjectDetail({ + group, + hasOtherMembers, +}: { + group: SidebarProjectSnapshot; + hasOtherMembers: boolean; +}) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); @@ -231,7 +249,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); const representative = group.memberProjects.find( - (member) => member.environmentId === group.environmentId && member.id === group.id, + (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 @@ -252,12 +270,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { environmentById.get(member.environmentId)?.serverConfig?.settings .projectAgentBrowserAccessOverrides[member.id], ); - const browserOverride = browserOverrides[0]; + 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) !== + (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== (browserOverride ?? projectSettings.enableAgentBrowserAccess) ); }); @@ -413,12 +432,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const config = environmentById.get(member.environmentId)?.serverConfig; return ( !Equal.equals(member.defaultModelSelection, storedSelection) || - JSON.stringify( - resolveDefaultProviderModelSelection( - config?.providers ?? EMPTY_SERVER_PROVIDERS, - member.defaultModelSelection ?? config?.settings.defaultModelSelection ?? null, - ), - ) !== JSON.stringify(resolvedSelection) + (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; @@ -597,7 +619,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { input: { patch: { projectScriptOverrides: { - ...scriptSettings.projectScriptOverrides, [selectedCheckout.id]: nextScripts, }, }, @@ -672,7 +693,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { reportFailure, selectedCheckout.environmentId, selectedCheckout.id, - scriptSettings.projectScriptOverrides, scriptSettings.defaultProjectScripts, updateServerSettings, upsertKeybinding, @@ -771,14 +791,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const isWholeGroup = members.length === group.memberProjects.length; + const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? group.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( [ projectThreads.length > 0 - ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` - : `Remove project "${targetLabel}"?`, + ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` + : `Remove ${targetKind} "${targetLabel}"?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, @@ -792,7 +813,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { "This permanently clears conversation history for those threads and any archived threads.", ] : ["This permanently clears any archived conversation history."]), - isWholeGroup + isWholeGroup && !hasOtherMembers ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", @@ -844,6 +865,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { deleteProject, group.displayName, group.memberProjects.length, + hasOtherMembers, navigate, reportFailure, threads, @@ -1324,12 +1346,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { 1 ? "Remove this project everywhere" : "Remove project" + hasOtherMembers + ? "Remove checkout" + : group.memberProjects.length > 1 + ? "Remove this project everywhere" + : "Remove project" } description={ - group.memberProjects.length > 1 - ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` - : "Deletes the project entry and its threads. Files on disk are not touched." + hasOtherMembers + ? "Deletes the selected machine's checkout entries and their threads. Other machines and files on disk are not touched." + : group.memberProjects.length > 1 + ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` + : "Deletes the project entry and its threads. Files on disk are not touched." } control={ } /> diff --git a/apps/web/src/components/settings/ProjectsSettings.tsx b/apps/web/src/components/settings/ProjectsSettings.tsx index 731bd9e119b9..4eedaab63846 100644 --- a/apps/web/src/components/settings/ProjectsSettings.tsx +++ b/apps/web/src/components/settings/ProjectsSettings.tsx @@ -3,6 +3,7 @@ import { FolderIcon } from "lucide-react"; import type { ReactNode } from "react"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { ProjectFavicon } from "../ProjectFavicon"; +import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { useEnvironments } from "../../state/environments"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -87,7 +88,7 @@ export function ProjectsSettings({ return (

-
+
-
+
{machineId !== null && !machine ? (

This machine is no longer available.

diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index ca7a6af4d175..27da1deaacbc 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -62,6 +62,44 @@ describe("serverSettings helpers", () => { ).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); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index f39b6dbd8828..2c61b425a175 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -242,7 +242,12 @@ export function applyServerSettingsPatch( ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), ...(patch.projectScriptOverrides !== undefined - ? { projectScriptOverrides: patch.projectScriptOverrides } + ? { + projectScriptOverrides: { + ...current.projectScriptOverrides, + ...patch.projectScriptOverrides, + }, + } : {}), ...(usageLimitSourcesPatch !== undefined ? { From e35d0042ed939d442501978b267067bfc67f9f11 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:37:58 +0000 Subject: [PATCH 07/11] fix(settings): stay on remaining project after checkout removal --- .../components/settings/ProjectSettingsPanel.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 3db3ce628a5d..790028fd0699 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -855,16 +855,23 @@ function ProjectDetail({ draftStore.clearProjectDraftThreadId(projectRef); } - // The project's settings page just deleted itself; there is no projects - // listing to fall back to, so leave settings entirely. if (isWholeGroup) { - void navigate({ to: "/", replace: true }); + if (hasOtherMembers) { + void navigate({ + to: "/settings/projects", + search: { project: group.projectKey, machine: undefined }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } } }, [ deleteProject, group.displayName, group.memberProjects.length, + group.projectKey, hasOtherMembers, navigate, reportFailure, From ccd21ffe6d10d7e22e6ff771862f2e3460ac9bb0 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:53:12 +0000 Subject: [PATCH 08/11] fix(settings): preserve independent project overrides and shortcuts --- .../settings/ProjectSettingsPanel.tsx | 163 +++++++++++------- .../src/state/sharedSettings.test.ts | 21 ++- .../src/state/sharedSettings.ts | 1 - packages/contracts/src/settings.ts | 8 +- packages/shared/src/serverSettings.test.ts | 39 ++++- packages/shared/src/serverSettings.ts | 20 ++- 6 files changed, 176 insertions(+), 76 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 790028fd0699..319705cf5dc9 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -261,6 +261,7 @@ function ProjectDetail({ const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); + const projects = useProjects(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); @@ -307,15 +308,11 @@ function ProjectDetail({ }); return; } - const overrides = { - ...environment.serverConfig.settings[key], - }; - for (const member of group.memberProjects.filter( - (member) => member.environmentId === environmentId, - )) { - if (enabled === undefined) delete overrides[member.id]; - else overrides[member.id] = enabled; - } + const overrides = Object.fromEntries( + group.memberProjects + .filter((member) => member.environmentId === environmentId) + .map((member) => [member.id, enabled ?? null]), + ); const result = await updateServerSettings({ environmentId, input: { patch: { [key]: overrides } }, @@ -608,11 +605,6 @@ function ProjectDetail({ savingScriptsRef.current = true; setIsSavingScripts(true); try { - // Captured before the write so a cleared or deleted binding can be - // removed from the keybindings config afterwards. - const previousKeybinding = keybindingCommand - ? keybindingValueForCommand(keybindings, keybindingCommand) - : null; const updateResult = mapAtomCommandResult( await updateServerSettings({ environmentId: selectedCheckout.environmentId, @@ -631,53 +623,82 @@ function ProjectDetail({ return updateResult; } - if (!keybindingCommand) return updateResult; - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: keybindingCommand, - }); if (!isElectron) return updateResult; - const environmentIds = [selectedCheckout.environmentId]; - const previousTarget = previousKeybinding - ? decodeProjectScriptKeybindingRule({ - keybinding: previousKeybinding, - command: keybindingCommand, - }) - : null; - if (keybindingRule) { - // `replace` swaps the command's previous rule instead of appending a - // second one that would keep the old shortcut alive. - const input = - previousTarget && previousTarget.key !== keybindingRule.key - ? { ...keybindingRule, replace: previousTarget } - : keybindingRule; - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await upsertKeybinding({ environmentId, input }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to save keybinding", result); - return result; + const changedCommands = keybindingCommand + ? [keybindingCommand] + : scripts + .filter( + (script) => + !(nextScripts ?? scriptSettings.defaultProjectScripts).some( + (nextScript) => nextScript.id === script.id, + ), + ) + .map((script) => commandForProjectScript(script.id)); + for (const changedCommand of changedCommands) { + const previousKeybinding = keybindingValueForCommand(keybindings, changedCommand); + const keybindingRule = decodeProjectScriptKeybindingRule({ + keybinding, + command: changedCommand, + }); + const environmentIds = [selectedCheckout.environmentId]; + const previousTarget = previousKeybinding + ? decodeProjectScriptKeybindingRule({ + keybinding: previousKeybinding, + command: changedCommand, + }) + : null; + if (keybindingRule) { + // `replace` swaps the command's previous rule instead of appending a + // second one that would keep the old shortcut alive. + const input = + previousTarget && previousTarget.key !== keybindingRule.key + ? { ...keybindingRule, replace: previousTarget } + : keybindingRule; + for (const environmentId of environmentIds) { + const result = mapAtomCommandResult( + await upsertKeybinding({ environmentId, input }), + () => undefined, + ); + if (result._tag === "Failure") { + reportFailure("Failed to save keybinding", result); + return result; + } } - } - } else if ( - previousTarget && - !( - scriptSettings.defaultProjectScripts.some( - (script) => commandForProjectScript(script.id) === keybindingCommand, - ) && - !nextScripts?.some((script) => commandForProjectScript(script.id) === keybindingCommand) - ) - ) { - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await removeKeybinding({ environmentId, input: previousTarget }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to remove keybinding", result); - return result; + } else if ( + previousTarget && + !( + !nextScripts?.some( + (script) => commandForProjectScript(script.id) === changedCommand, + ) && + (scriptSettings.defaultProjectScripts.some( + (script) => commandForProjectScript(script.id) === changedCommand, + ) || + Object.entries(scriptSettings.projectScriptOverrides).some( + ([projectId, overrides]) => + projectId !== selectedCheckout.id && + overrides?.some( + (script) => commandForProjectScript(script.id) === changedCommand, + ), + ) || + projects.some( + (project) => + project.environmentId === selectedCheckout.environmentId && + project.id !== selectedCheckout.id && + resolveProjectScripts(scriptSettings, project).some( + (script) => commandForProjectScript(script.id) === changedCommand, + ), + )) + ) + ) { + for (const environmentId of environmentIds) { + const result = mapAtomCommandResult( + await removeKeybinding({ environmentId, input: previousTarget }), + () => undefined, + ); + if (result._tag === "Failure") { + reportFailure("Failed to remove keybinding", result); + return result; + } } } } @@ -693,7 +714,9 @@ function ProjectDetail({ reportFailure, selectedCheckout.environmentId, selectedCheckout.id, - scriptSettings.defaultProjectScripts, + scriptSettings, + scripts, + projects, updateServerSettings, upsertKeybinding, ], @@ -883,7 +906,17 @@ function ProjectDetail({ projectGroupingSettings.sidebarProjectGroupingOverrides?.[ deriveProjectGroupingOverrideKey(selectedCheckout) ] ?? "inherit"; - const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine"; + 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); return ( <> @@ -1190,12 +1223,16 @@ function ProjectDetail({ }} > - {selectedCheckoutLabel} + + {selectedCheckoutLabel} + {group.memberProjects.map((member) => ( - {member.environmentLabel ?? "This machine"} + + {checkoutLabel(member)} + ))} diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 8c46a9f33579..a67f99d10ceb 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -41,16 +41,24 @@ describe("splitSharedServerPatch", () => { sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false, enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", + newWorktreesStartFromOrigin: true, + }); + expect(sharedPatch).toEqual({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: false, + newWorktreesStartFromOrigin: true, + }); + expect(localPatch).toEqual({ + enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", }); - expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false }); - expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); }); }); describe("pickSharedServerSettings", () => { it("returns only the shared keys", () => { expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([ - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", @@ -99,7 +107,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 f3236ef2035a..51e0e1359eca 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -23,7 +23,6 @@ import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; export const SHARED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", ] as const satisfies ReadonlyArray; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 88e2b266c133..7ed0e7e228c0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1130,13 +1130,17 @@ 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.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.Boolean)), + 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), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 27da1deaacbc..4b5cb2695ffb 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -110,7 +110,9 @@ describe("serverSettings helpers", () => { projectAutoPullOverrides: { [projectId]: false }, }); expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false); - const reset = applyServerSettingsPatch(overridden, { projectAutoPullOverrides: {} }); + const reset = applyServerSettingsPatch(overridden, { + projectAutoPullOverrides: { [projectId]: null }, + }); expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true); const disabled = applyServerSettingsPatch(reset, { defaultAutoPull: false, @@ -128,7 +130,9 @@ describe("serverSettings helpers", () => { }); expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false); expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true); - const reset = applyServerSettingsPatch(overridden, { projectAgentBrowserAccessOverrides: {} }); + const reset = applyServerSettingsPatch(overridden, { + projectAgentBrowserAccessOverrides: { [projectId]: null }, + }); expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true); const enabled = applyServerSettingsPatch(reset, { enableAgentBrowserAccess: false, @@ -138,6 +142,37 @@ describe("serverSettings helpers", () => { 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", [ diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 2c61b425a175..5b3928eb4681 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -173,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); @@ -229,11 +231,21 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), - ...(patch.projectAgentBrowserAccessOverrides !== undefined - ? { projectAgentBrowserAccessOverrides: patch.projectAgentBrowserAccessOverrides } + ...(projectAgentBrowserAccessOverridesPatch !== undefined + ? { + projectAgentBrowserAccessOverrides: mergeSettingsEntries( + current.projectAgentBrowserAccessOverrides, + projectAgentBrowserAccessOverridesPatch, + ), + } : {}), - ...(patch.projectAutoPullOverrides !== undefined - ? { projectAutoPullOverrides: patch.projectAutoPullOverrides } + ...(projectAutoPullOverridesPatch !== undefined + ? { + projectAutoPullOverrides: mergeSettingsEntries( + current.projectAutoPullOverrides, + projectAutoPullOverridesPatch, + ), + } : {}), ...(patch.defaultModelSelection !== undefined ? { defaultModelSelection: patch.defaultModelSelection } From c2b2873a906c9ab2bb81c3d852f0d2282efb3ece Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:53:37 +0000 Subject: [PATCH 09/11] style(settings): format checkout selector --- apps/web/src/components/settings/ProjectSettingsPanel.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 319705cf5dc9..8d9d8da92ebe 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -1223,9 +1223,7 @@ function ProjectDetail({ }} > - - {selectedCheckoutLabel} - + {selectedCheckoutLabel} {group.memberProjects.map((member) => ( From f5dbc3aef4e21dfb8e5f17d2ca6bdedb5ca00c4b Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:56:23 +0000 Subject: [PATCH 10/11] fix(settings): resolve workspace from selected checkout --- apps/web/src/components/settings/ProjectSettingsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 8d9d8da92ebe..bbd1028ae7d4 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -576,7 +576,7 @@ function ProjectDetail({ // 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 ?? projectSettings.defaultThreadEnvMode; + t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode; const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; const importableScripts = useMemo( () => From 13dfd0cefe3da34e8cd9476d771236f23e4dd037 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:05:56 +0000 Subject: [PATCH 11/11] fix(settings): report failed writes and preflight machine availability --- .../settings/ProjectDefaultsSettings.tsx | 10 ++++++- .../settings/ProjectSettingsPanel.tsx | 29 ++++++++++++------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index a3bd255dcfaa..55777b6aa934 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -137,11 +137,19 @@ export function ProjectDefaultsSettings({ savingRef.current = true; setSaving(true); try { - await Promise.all( + const results = await Promise.all( targets.map((target) => updateSettings({ environmentId: target.environmentId, input: { patch } }), ), ); + const failedTargets = targets.filter((_, index) => results[index]?._tag === "Failure"); + if (failedTargets.length > 0) { + toastManager.add({ + type: "error", + title: "Project defaults not saved on every machine", + description: `Could not update ${failedTargets.map((target) => target.label).join(", ")}. Other machines may have saved the change.`, + }); + } } finally { savingRef.current = false; setSaving(false); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 5ea0a1c8726e..b9f94e18b389 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -289,16 +289,8 @@ function ProjectDetail({ savingBrowserAccessRef.current = true; setSavingBrowserAccess(true); try { - if (key === "projectAutoPullOverrides" && enabled === undefined) { - const result = await updateAllMembers( - { autoPull: false }, - "Failed to reset automatic pull", - ); - if (result._tag === "Failure") return; - } - for (const environmentId of new Set( - group.memberProjects.map((member) => member.environmentId), - )) { + 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({ @@ -308,6 +300,15 @@ function ProjectDetail({ }); return; } + } + if (key === "projectAutoPullOverrides" && enabled === undefined) { + const result = await updateAllMembers( + { autoPull: false }, + "Failed to reset automatic pull", + ); + if (result._tag === "Failure") return; + } + for (const environmentId of environmentIds) { const overrides = Object.fromEntries( group.memberProjects .filter((member) => member.environmentId === environmentId) @@ -317,7 +318,13 @@ function ProjectDetail({ environmentId, input: { patch: { [key]: overrides } }, }); - if (result._tag === "Failure") return; + if (result._tag === "Failure") { + reportFailure( + `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, + mapAtomCommandResult(result, () => undefined), + ); + return; + } } } finally { savingBrowserAccessRef.current = false;