{projectDisplayName ? (
-
+ {project ?
: null}
{projectDisplayName}
) : null}
@@ -703,16 +690,13 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
draftId: DraftId;
session: DraftSessionState;
composer: ComposerThreadDraftState;
- projectTitle: string | null;
+ project: ProjectFaviconProject | null;
projectDisplayName: string | null;
- projectCwd: string | null;
- projectFaviconPath: string | null;
- projectIcon: ProjectIconOverride | null;
isActive: boolean;
onNavigate: (draftId: DraftId) => void;
onDiscard: (draftId: DraftId) => void;
}) {
- const { composer, draftId, onDiscard, onNavigate, session } = props;
+ const { composer, draftId, onDiscard, onNavigate } = props;
const promptPreview = composer.prompt.trim().split("\n", 1)[0] ?? "";
// images mirrors persistedAttachments once rehydration finishes; before
// that only the persisted list is populated, hence max not sum.
@@ -765,14 +749,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
-
+ {props.project ? (
+
+ ) : null}
{props.projectDisplayName}
@@ -812,11 +791,8 @@ interface SidebarDraftRowData {
// subscription + closing divider) so per-keystroke composer updates
// re-render only this block, never the whole sidebar. Vanishes at count 0.
const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
- projectTitleByKey: ReadonlyMap
;
+ projectByKey: ReadonlyMap;
projectDisplayNameByKey: ReadonlyMap;
- projectCwdByKey: ReadonlyMap;
- projectFaviconPathByKey: ReadonlyMap;
- projectIconByKey: ReadonlyMap;
scopedProjectKeys: ReadonlySet | null;
routeDraftId: string | null;
onNavigateToDraft: (draftId: DraftId) => void;
@@ -910,11 +886,8 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
draftId={draftId}
session={session}
composer={composer}
- projectTitle={props.projectTitleByKey.get(projectKey) ?? null}
+ project={props.projectByKey.get(projectKey) ?? null}
projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null}
- projectCwd={props.projectCwdByKey.get(projectKey) ?? null}
- projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null}
- projectIcon={props.projectIconByKey.get(projectKey) ?? null}
isActive={draftId === props.routeDraftId}
onNavigate={props.onNavigateToDraft}
onDiscard={handleDiscard}
@@ -1001,10 +974,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
currentEnvironmentId: string | null;
environmentLabel: string | null;
environmentMachine: EnvironmentMachineKind;
- projectCwd: string | null;
- projectFaviconPath: string | null;
- projectIcon: ProjectIconOverride | null;
- projectTitle: string | null;
+ project: EnvironmentProject | null;
projectDisplayName: string | null;
providerEntryByInstanceId: ReadonlyMap;
timestampFormat: TimestampFormat;
@@ -1089,7 +1059,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
[clearComposerContent, threadRef],
);
- const gitCwd = thread.worktreePath ?? props.projectCwd;
+ const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
const linkedPullRequestStatus = useLinkedThreadPullRequest(
leaseLiveStatus ? thread.environmentId : null,
leaseLiveStatus ? (thread.linkedPullRequest ?? thread.branchPullRequest) : null,
@@ -1254,11 +1224,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
const detailsTooltip = (
-
+ {props.project ? : null}
{draftIndicator}
{title}
@@ -1827,14 +1787,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{draftIndicator}
-
+ {props.project ? (
+
+ ) : null}
{props.projectDisplayName ? (
}
>
-
+ {props.project ? (
+
+ ) : null}
{thread.title}
{threadTimeLabel(thread)}
@@ -2193,11 +2140,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
- new Map(
- projects.map((project) => [
- `${project.environmentId}:${project.id}`,
- project.workspaceRoot,
- ]),
- ),
- [projects],
- );
- const projectFaviconPathByKey = useMemo(
- () =>
- new Map(
- projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]),
- ),
- [projects],
- );
- const projectIconByKey = useMemo(
- () =>
- new Map(
- projects.map((project) => [`${project.environmentId}:${project.id}`, project.projectIcon]),
- ),
- [projects],
- );
- // Icons use saved titles. Group labels can include a repository owner or a different title.
- const projectTitleByKey = useMemo(
- () =>
- new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])),
+ // Rows read the project record for its icon and cwd. Group labels can include
+ // a repository owner or a different title, so they travel separately.
+ const projectByKey = useMemo(
+ () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])),
[projects],
);
const projectDisplayNameByKey = useMemo(
@@ -4086,7 +4006,7 @@ export default function Sidebar() {
if (!thread) return;
const threadWorkspacePath =
thread.worktreePath ??
- projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
+ projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ??
null;
// Un-settle pins the thread active until real activity clears the pin.
// Environments without
@@ -4297,7 +4217,7 @@ export default function Sidebar() {
deleteThread,
handleMultiSelectContextMenu,
markThreadUnread,
- projectCwdByKey,
+ projectByKey,
serverConfigs,
startThreadRename,
updateThreadMetadata,
@@ -4554,14 +4474,7 @@ export default function Sidebar() {
>
{scopedProjectGroup ? (
-
+
) : (
@@ -4575,48 +4488,34 @@ export default function Sidebar() {
align="start"
className="w-(--anchor-width) min-w-0 overflow-hidden"
>
-
-
-
- {
- if (
- event.defaultPrevented ||
- event.nativeEvent.isComposing ||
- event.ctrlKey ||
- event.altKey ||
- event.metaKey ||
- (event.key !== "ContextMenu" &&
- !(event.shiftKey && event.key === "F10"))
- ) {
- return;
- }
- // Combobox items use virtual focus: keyboard events
- // stay on this input, not on the highlighted option.
- const scopeKey = highlightedProjectScopeKeyRef.current;
- const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null;
- if (project) handleProjectSettings(event, project);
- }}
- onChange={(event) =>
- dispatchProjectScopeMenu({
- type: "query-changed",
- query: event.target.value,
- })
- }
- />
-
-
+ {
+ if (
+ event.defaultPrevented ||
+ event.nativeEvent.isComposing ||
+ event.ctrlKey ||
+ event.altKey ||
+ event.metaKey ||
+ (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
+ ) {
+ return;
+ }
+ // Combobox items use virtual focus: keyboard events
+ // stay on this input, not on the highlighted option.
+ const scopeKey = highlightedProjectScopeKeyRef.current;
+ const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null;
+ if (project) handleProjectSettings(event, project);
+ }}
+ onChange={(event) =>
+ dispatchProjectScopeMenu({
+ type: "query-changed",
+ query: event.target.value,
+ })
+ }
+ />
No matching projects.
{(item: (typeof projectScopeItems)[number]) => {
@@ -4633,14 +4532,7 @@ export default function Sidebar() {
}}
>
{project ? (
-
+
) : (
)}
@@ -4715,21 +4607,8 @@ export default function Sidebar() {
{projectLabel ? (
- {props.projectCwd ? (
-
+ {props.project ? (
+
) : null}
{projectLabel}
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index cee63b5875..59e027c14a 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -6,6 +6,7 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
@@ -54,10 +55,7 @@ interface ChatHeaderProps {
activeThreadTitle: string;
/** Drafts have no server thread yet, so the title carries no action menu. */
isServerThread: boolean;
- activeProjectName: string | undefined;
- activeProjectCwd: string | null;
- activeProjectFaviconPath: string | null;
- activeProjectIcon: import("@t3tools/contracts").ProjectIconOverride | null;
+ activeProject: EnvironmentProject | null;
openInCwd: string | null;
activeProjectScripts: ReadonlyArray | undefined;
preferredScriptId: string | null;
@@ -126,10 +124,7 @@ export const ChatHeader = memo(function ChatHeader({
draftId,
activeThreadTitle,
isServerThread,
- activeProjectName,
- activeProjectCwd,
- activeProjectFaviconPath,
- activeProjectIcon,
+ activeProject,
openInCwd,
activeProjectScripts,
preferredScriptId,
@@ -161,6 +156,8 @@ export const ChatHeader = memo(function ChatHeader({
});
}, [panelAnimationDurationMs, panelAnimationsActive]);
const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const activeProjectName = activeProject?.title;
+ const activeProjectCwd = activeProject?.workspaceRoot ?? null;
const fileScripts = useT3ProjectFileScripts(
activeThreadEnvironmentId,
activeProjectScripts ? activeProjectCwd : null,
@@ -324,7 +321,7 @@ export const ChatHeader = memo(function ChatHeader({
{/* The project always leads the header: knowing which project a
thread lives in is priority zero, and the thread title alone
doesn't answer it. */}
- {activeProjectName ? (
+ {activeProject ? (
<>
@@ -338,14 +335,7 @@ export const ChatHeader = memo(function ChatHeader({
/>
}
>
-
+
{activeProjectName}
New thread in {activeProjectName}
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx
index e17d3d7380..4a9421f201 100644
--- a/apps/web/src/components/chat/DraftHeroHeadline.tsx
+++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx
@@ -150,8 +150,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,
});
}
@@ -166,14 +171,7 @@ export function DraftHeroHeadline({
closeOnClick
className="[&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"
>
-
+
}>
{group.displayName}
diff --git a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx
new file mode 100644
index 0000000000..0d45845339
--- /dev/null
+++ b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx
@@ -0,0 +1,160 @@
+import { useAtomValue } from "@effect/atom-react";
+import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
+import { Atom } from "effect/unstable/reactivity";
+import { useMemo, useState } from "react";
+
+import type { EnvironmentPresentation } from "~/state/environments";
+import { serverEnvironment } from "~/state/server";
+import {
+ buildVersionMismatchDismissalKey,
+ dismissServerUpdateFailure,
+ dismissVersionMismatch,
+ isServerUpdateFailureDismissed,
+ isVersionMismatchDismissed,
+ resolveServerConfigVersionMismatch,
+ resolveServerSelfUpdateCapability,
+ supportsDesktopAppUpdate,
+ supportsServerUpdateThreadContinuation,
+} from "~/versionSkew";
+import {
+ ServerUpdateAction,
+ ServerUpdateProgress,
+ ServerUpdatesAction,
+} from "../ServerUpdateAction";
+import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
+import type { ComposerBannerStackItem } from "./ComposerBannerStack";
+import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus";
+
+/** Keep every machine's update visible while auto balance has no single update target. */
+export function useAutoBalanceUpdateBanner(
+ environments: readonly EnvironmentPresentation[],
+): ComposerBannerStackItem | null {
+ const statesAtom = useMemo(
+ () =>
+ Atom.make((get) =>
+ environments.map((environment) => ({
+ environment,
+ state: get(serverEnvironment.updateStateAtom(environment.environmentId)),
+ })),
+ ),
+ [environments],
+ );
+ const states = useAtomValue(statesAtom);
+ const [dismissedNotices, setDismissedNotices] = useState>(
+ () => new Set(),
+ );
+ const machines = states.flatMap(({ environment, state }) => {
+ const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
+ const dismissKey = mismatch
+ ? buildVersionMismatchDismissalKey(environment.environmentId, mismatch)
+ : null;
+ if (
+ state.status === "idle"
+ ? !mismatch ||
+ (dismissKey !== null && dismissedNotices.has(dismissKey)) ||
+ isVersionMismatchDismissed(dismissKey)
+ : dismissedNotices.has(state) || isServerUpdateFailureDismissed(state)
+ )
+ return [];
+ const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig);
+ const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig);
+ return [
+ {
+ environmentId: environment.environmentId,
+ serverLabel: environment.label,
+ selfUpdate,
+ desktopAppUpdate,
+ threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),
+ continueThreadsAfterServerUpdate:
+ environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false,
+ targetVersion: state.status === "idle" ? mismatch!.clientVersion : state.targetVersion,
+ connected: environment.connection.phase === "connected",
+ remoteUpdate: selfUpdate !== null && (selfUpdate !== "desktop-managed" || desktopAppUpdate),
+ state,
+ dismissKey,
+ },
+ ];
+ });
+ if (machines.length === 0) return null;
+
+ const running = machines.filter((machine) => machine.state.status === "running").length;
+ const failed = machines.filter((machine) => machine.state.status === "failed").length;
+ const manual = machines.filter((machine) => !machine.remoteUpdate).length;
+ const targets = machines.filter(
+ (machine) => machine.connected && machine.remoteUpdate && machine.state.status !== "running",
+ );
+ const count = running || failed || machines.length;
+ const status = running ? "running" : failed ? "failed" : "idle";
+ const prefix = running ? "Updating" : failed ? "Could not update" : "Update available for";
+ const title = `${prefix} ${count} ${count === 1 ? "machine" : "machines"}`;
+ return {
+ id: `auto-balance-server-updates-${dismissedNotices.size}`,
+ variant: failed ? "error" : "default",
+ priority: running ? "urgent" : "notice",
+ icon: ,
+ title: (
+
+
+ {title}
+
+
+
+ {machines.map((machine) => (
+
+
{machine.serverLabel}
+ {machine.state.status !== "idle" ? (
+
+ ) : !machine.remoteUpdate ? (
+ <>
+
Manual update required
+
+ >
+ ) : (
+
+ {machine.connected
+ ? `Ready to update to ${machine.targetVersion}`
+ : "Reconnect this machine to update"}
+
+ )}
+
+ ))}
+
+
+
+ ),
+ description:
+ manual > 0 ? `${manual} ${manual === 1 ? "needs" : "need"} a manual update` : undefined,
+ actions:
+ running === 0 && targets.length > 0 ? (
+ 0
+ ? "Retry"
+ : targets.length === machines.length
+ ? "Update all"
+ : `Update ${targets.length} ${targets.length === 1 ? "machine" : "machines"}`
+ }
+ />
+ ) : undefined,
+ dismissLabel: "Dismiss update notice",
+ ...(running
+ ? {}
+ : {
+ onDismiss: () => {
+ const next = new Set(dismissedNotices);
+ for (const { state, dismissKey } of machines) {
+ dismissServerUpdateFailure(state);
+ dismissVersionMismatch(dismissKey);
+ if (dismissKey) next.add(dismissKey);
+ if (state.status === "failed") next.add(state);
+ }
+ setDismissedNotices(next);
+ },
+ }),
+ };
+}
diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx
index e97a46a2a2..3e941e69dd 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/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index 113bb44953..5ecfe01e5f 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -53,14 +53,21 @@ import {
import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
+import { useClientSettings } from "~/hooks/useSettings";
import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
+import {
+ deriveLogicalProjectKeyFromSettings,
+ derivePhysicalProjectKey,
+ selectProjectGroupingSettings,
+} from "~/logicalProject";
import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";
import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import type { ReviewCommentContext } from "~/reviewCommentContext";
+import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";
import { useProjects } from "~/state/entities";
-import { useEnvironments } from "~/state/environments";
+import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
import { useEnvironmentQuery } from "~/state/query";
import { useLiveRefresh } from "~/hooks/useLiveRefresh";
import {
@@ -71,6 +78,7 @@ import {
import { useAtomCommand } from "~/state/use-atom-command";
import { vcsEnvironment } from "~/state/vcs";
import { formatRelativeTimeLabel } from "~/timestampFormat";
+import { useUiStateStore } from "~/uiStateStore";
import {
AlertDialog,
@@ -124,11 +132,13 @@ import {
pullRequestCheckoutCommand,
pullRequestFindingKey,
pullRequestHandoffLabels,
+ PULL_REQUEST_MERGE_METHOD_LABELS,
readableFailure,
readPullRequestDetailSnapshot,
resolveDisplayedPullRequestDetail,
resolvePullRequestPrimaryControl,
resolveBaseFreshness,
+ resolvePullRequestMergeMethod,
type PullRequestFinding,
shouldRefreshPullRequestActivity,
writePullRequestDetailSnapshot,
@@ -168,12 +178,6 @@ const ACTION_SUCCESS_LABELS: Record = {
"approve-workflows": "Workflows approved",
};
-const MERGE_METHOD_LABELS: Record = {
- merge: "Merge",
- squash: "Squash",
- rebase: "Rebase",
-};
-
/** Said as the thing that did not happen, rather than as the operation that returned an error. */
const ACTION_FAILURE_LABELS: Record = {
merge: "Could not merge this pull request",
@@ -557,12 +561,16 @@ export function PullRequestDetailPanel({
compensationRef.current = null;
if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);
}, [condensed]);
+ const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
+ const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);
+ const mergeMethodOverrides = useClientSettings(
+ (settings) => settings.pullRequestMergeMethodOverrides,
+ );
+ const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const [mergeMethodSelection, setMergeMethodSelection] = useState<{
readonly pullRequestKey: string;
readonly method: PullRequestMergeMethod;
- }>(() => ({ pullRequestKey, method: "merge" }));
- const mergeMethod =
- mergeMethodSelection.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : "merge";
+ } | null>(null);
const setMergeMethod = (method: PullRequestMergeMethod) => {
setMergeMethodSelection({ pullRequestKey, method });
};
@@ -767,6 +775,7 @@ export function PullRequestDetailPanel({
const [titleSaving, setTitleSaving] = useState(false);
const newThread = useNewThreadHandler();
const { environments } = useEnvironments();
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
const projects = useProjects();
const unavailableGitHubUrl = useMemo(() => {
const identity = projects.find(
@@ -774,6 +783,30 @@ export function PullRequestDetailPanel({
)?.repositoryIdentity;
return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);
}, [environmentId, projects, reference.number, reference.projectId, reference.repository]);
+ // Project settings store the override under the sidebar group's key, which a duplicate row
+ // borrows from its siblings, so the project alone does not always name the same key.
+ const projectDefaultMergeMethod = useMemo(() => {
+ const project = projects.find(
+ (candidate) =>
+ candidate.environmentId === environmentId && candidate.id === reference.projectId,
+ );
+ if (!project) return undefined;
+ const projectKey =
+ buildPhysicalToLogicalProjectKeyMap({
+ projects,
+ settings: projectGroupingSettings,
+ primaryEnvironmentId,
+ }).get(derivePhysicalProjectKey(project)) ??
+ deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings);
+ return mergeMethodOverrides[projectKey];
+ }, [
+ environmentId,
+ mergeMethodOverrides,
+ primaryEnvironmentId,
+ projectGroupingSettings,
+ projects,
+ reference.projectId,
+ ]);
// Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and
// the thread is already on one server's copy of the branch.
const pickableEnvironments = useMemo(
@@ -1238,10 +1271,15 @@ export function PullRequestDetailPanel({
const allowedMergeMethods = detail
? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method])
: [];
- const selectedMergeMethod = allowedMergeMethods.includes(mergeMethod)
- ? mergeMethod
- : (allowedMergeMethods[0] ?? "merge");
- const selectedMergeMethodLabel = MERGE_METHOD_LABELS[selectedMergeMethod];
+ const currentMergeMethod =
+ mergeMethodSelection?.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : null;
+ const selectedMergeMethod = resolvePullRequestMergeMethod(
+ allowedMergeMethods,
+ currentMergeMethod,
+ projectDefaultMergeMethod,
+ lastSelectedMergeMethod,
+ );
+ const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];
const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`;
const conflicting = detail?.state === "open" && detail.mergeability === "conflicting";
// Only an outright yes arms it. A host that reports nothing has not said the merge is already
@@ -1249,7 +1287,7 @@ export function PullRequestDetailPanel({
const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true;
const armedMergeMethod = detail?.autoMergeMethod;
const armedAutoMergeLabel = armedMergeMethod
- ? `Auto-merge (${MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})`
+ ? `Auto-merge (${PULL_REQUEST_MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})`
: "Auto-merge";
const workflowApprovalsRequired =
detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0;
@@ -1787,17 +1825,24 @@ export function PullRequestDetailPanel({
) : null}
- setMergeMethod(method as PullRequestMergeMethod)
- }
+ onValueChange={(method) => {
+ const selectedMethod = method as PullRequestMergeMethod;
+ setMergeMethod(selectedMethod);
+ setLastSelectedMergeMethod(selectedMethod);
+ }}
>
{allowedMergeMethods.map((method) => (
-
+
{/* The radio item lays its children out as one block, so the
icon and the label need their own row to share a line. */}
- {MERGE_METHOD_LABELS[method]}
+ {PULL_REQUEST_MERGE_METHOD_LABELS[method]}
))}
diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx
index a18c4c04ee..2251979fcd 100644
--- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx
@@ -3,7 +3,6 @@ import type {
EnvironmentId,
ProjectId,
PullRequestInvolvement,
- ProjectIconOverride,
PullRequestListFilters,
PullRequestListState,
SourceControlProviderKind,
@@ -25,7 +24,7 @@ import {
import { type ElementType, useState } from "react";
import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
-import { ProjectFavicon } from "../ProjectFavicon";
+import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon";
import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
import { Button } from "../ui/button";
@@ -57,13 +56,7 @@ export interface PullRequestFilterOption {
readonly label: string;
/** Uses the option's native icon tone. */
readonly Icon: ElementType<{ className?: string }>;
- readonly favicon?: {
- readonly environmentId: EnvironmentId;
- readonly cwd: string;
- readonly faviconPath?: string | null | undefined;
- readonly projectIcon?: ProjectIconOverride | null | undefined;
- readonly projectName?: string | undefined;
- };
+ readonly project?: ProjectFaviconProject;
/** Why it cannot be chosen, carried onto the item as its title. */
readonly unavailable?: string | undefined;
}
@@ -73,15 +66,8 @@ export function PullRequestFilterOptionIcon({
}: {
option: PullRequestFilterOption;
}) {
- return option.favicon ? (
-
+ return option.project ? (
+
) : (
);
@@ -453,15 +439,13 @@ export function PullRequestFiltersMenu({
serverOptions: ReadonlyArray>;
onServer: (server: EnvironmentId | undefined) => void;
/** The projects of every connected environment, each carrying the one its favicon is read from. */
- projects: ReadonlyArray<{
- readonly id: ProjectId;
- readonly environmentId: EnvironmentId;
- readonly title: string;
- readonly workspaceRoot: string;
- readonly faviconPath?: string | null | undefined;
- readonly projectIcon?: ProjectIconOverride | null | undefined;
- readonly projectName?: string | undefined;
- }>;
+ projects: ReadonlyArray<
+ ProjectFaviconProject & {
+ readonly id: ProjectId;
+ /** The saved title when `title` carries a duplicate-disambiguating suffix. */
+ readonly projectName?: string | undefined;
+ }
+ >;
projectId: ProjectId | undefined;
/**
* The server the selected project belongs to. A project id is only unique within its own
@@ -516,13 +500,10 @@ export function PullRequestFiltersMenu({
value: pullRequestProjectKey(project),
label: project.title,
Icon: FolderGit2Icon,
- favicon: {
- environmentId: project.environmentId,
- cwd: project.workspaceRoot,
- faviconPath: project.faviconPath ?? null,
- projectName: project.projectName ?? project.title,
- projectIcon: project.projectIcon ?? null,
- },
+ // Duplicate titles gain a server or path suffix in this list; the icon still
+ // derives from the saved title so it matches the sidebar.
+ project:
+ project.projectName === undefined ? project : { ...project, title: project.projectName },
...(unavailable.has(pullRequestProjectKey(project))
? { unavailable: unavailable.get(pullRequestProjectKey(project)) }
: {}),
diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts
index d2c9c45231..0f150f6724 100644
--- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts
+++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts
@@ -37,6 +37,7 @@ import {
resolvePullRequestPrimaryControl,
shouldRefreshPullRequestActivity,
resolveBaseFreshness,
+ resolvePullRequestMergeMethod,
buildPullRequestTimeline,
editPullRequestThreadComment,
writePullRequestDetailSnapshot,
@@ -85,6 +86,21 @@ const TIMELINE_SOURCE: Pick<
closedAt: null,
};
+describe("pull request merge method", () => {
+ it("uses the current choice, then the project default, then the last choice", () => {
+ expect(
+ resolvePullRequestMergeMethod(["merge", "squash", "rebase"], null, "squash", "rebase"),
+ ).toBe("squash");
+ expect(
+ resolvePullRequestMergeMethod(["merge", "squash", "rebase"], "rebase", "squash", "merge"),
+ ).toBe("rebase");
+ expect(resolvePullRequestMergeMethod(["merge", "rebase"], null, "squash", "rebase")).toBe(
+ "rebase",
+ );
+ expect(resolvePullRequestMergeMethod(["squash"], null, "merge", "rebase")).toBe("squash");
+ });
+});
+
describe("pull request activity refresh", () => {
const first = {
key: "project:acme/web#7",
diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
index d23a876ff6..fe9bddacce 100644
--- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
+++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
@@ -11,6 +11,7 @@ import {
type PullRequestCommit,
type PullRequestDetailView,
type PullRequestMergeability,
+ type PullRequestMergeMethod,
type PullRequestReaction,
type PullRequestReviewThread,
type PullRequestState,
@@ -21,6 +22,24 @@ import {
import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";
+export const PULL_REQUEST_MERGE_METHOD_LABELS: Record = {
+ merge: "Merge",
+ squash: "Squash and merge",
+ rebase: "Rebase and merge",
+};
+
+export function resolvePullRequestMergeMethod(
+ allowed: ReadonlyArray,
+ current: PullRequestMergeMethod | null,
+ projectDefault: PullRequestMergeMethod | undefined,
+ lastSelected: PullRequestMergeMethod,
+): PullRequestMergeMethod {
+ for (const method of [current, projectDefault, lastSelected]) {
+ if (method && allowed.includes(method)) return method;
+ }
+ return allowed[0] ?? "merge";
+}
+
const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index af1368287c..994f4a8bbe 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -60,6 +60,7 @@ import {
useRelativeTimeTick,
} from "./settingsLayout";
import { searchableSetting } from "./settingsSearch";
+import { LoadBalancingSettings } from "./LoadBalancingSettings";
import { Input } from "../ui/input";
import { CommandShortcut } from "../ui/command";
import {
@@ -3595,6 +3596,7 @@ export function ConnectionsSettings() {
savedEnvironments={savedEnvironments}
/>
+
);
}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx
index 1a8dc7d6aa..5d185fee58 100644
--- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx
+++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx
@@ -1,4 +1,10 @@
import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+ RouterProvider,
+} from "@tanstack/react-router";
import { act, StrictMode, type ReactNode } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
@@ -44,13 +50,19 @@ afterEach(async () => {
});
async function openSettings() {
+ const router = createRouter({
+ routeTree: createRootRoute({ component: IntegrationsSettingsPanel }),
+ history: createMemoryHistory(),
+ });
+ await router.load();
await act(() => {
renderer = create(
-
+
,
);
});
+ expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined();
}
describe("Integrations browser discovery", () => {
diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx
index 11c3bd6156..2ea293280e 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, useRef, useState, type ReactNode } from "react";
@@ -86,7 +86,6 @@ import {
persistClientSettingsUpdate,
useClientSettings,
useClientSettingsHydrated,
- usePrimarySettings,
useUpdatePrimarySettings,
} from "~/hooks/useSettings";
@@ -552,39 +551,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) })
+
}
- aria-label="Allow agent browser access"
- />
+ size="sm"
+ variant="outline"
+ >
+ Project settings
+
}
/>
);
diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx
new file mode 100644
index 0000000000..c56c6f8d73
--- /dev/null
+++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx
@@ -0,0 +1,101 @@
+import { connectionStatusText } from "@t3tools/client-runtime/connection";
+
+import {
+ useClientSettings,
+ useClientSettingsHydrated,
+ useUpdateClientSettings,
+} from "~/hooks/useSettings";
+import type { EnvironmentPresentation } from "~/state/environments";
+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
+import { Switch } from "../ui/switch";
+import { SettingsRow, SettingsSection } from "./settingsLayout";
+import { searchableSetting } from "./settingsSearch";
+
+const preferences = [
+ { value: 100, label: "Prefer" },
+ { value: 50, label: "Normal" },
+ { value: 25, label: "Less often" },
+ { value: 0, label: "Manual only" },
+];
+
+export function LoadBalancingSettings({
+ environments,
+}: {
+ environments: ReadonlyArray;
+}) {
+ const settings = useClientSettings();
+ const settingsHydrated = useClientSettingsHydrated();
+ const updateSettings = useUpdateClientSettings();
+
+ if (environments.length < 2) {
+ return (
+
+
+ Connect another machine to automatically balance load across environments.
+
+
+ );
+ }
+
+ return (
+
+ updateSettings({ loadBalancingEnabled })}
+ />
+ }
+ />
+ {environments.map((environment) => {
+ const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50;
+ // Keep saved slider weights until the user chooses a different preference.
+ const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100;
+
+ return (
+ {
+ if (value !== null) {
+ updateSettings({
+ loadBalancingWeights: {
+ ...settings.loadBalancingWeights,
+ [environment.environmentId]: value,
+ },
+ });
+ }
+ }}
+ >
+
+
+
+
+ {preferences.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
+
+ }
+ />
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx
new file mode 100644
index 0000000000..1794a5fdaa
--- /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 0000000000..89a0e03e8e
--- /dev/null
+++ b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx
@@ -0,0 +1,117 @@
+import type { EnvironmentId } from "@t3tools/contracts";
+import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
+import { PlusIcon } from "lucide-react";
+import { useState } from "react";
+import { useEnvironments } from "../../state/environments";
+import {
+ EMPTY_PROJECT_SCRIPT_INPUT,
+ editorRequestForScript,
+ ProjectScriptEditorDialog,
+ type ProjectScriptEditorRequest,
+} from "../projectScriptEditor";
+import { Button } from "../ui/button";
+import { ProjectActionsList } from "./ProjectActionsList";
+import { useProjectScriptSettings } from "./ProjectSettingsPanel";
+import { supportsProjectDefaults } from "./ProjectSettingsPanel.logic";
+import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
+
+export function ProjectDefaultActionsSettings({
+ environmentId,
+}: {
+ environmentId: EnvironmentId | null;
+}) {
+ const { environments } = useEnvironments();
+ const targets = environments.filter(
+ (environment) =>
+ (environmentId === null || environment.environmentId === environmentId) &&
+ environment.connection.phase === "connected" &&
+ // Older servers have nowhere to keep default actions; the page names them.
+ supportsProjectDefaults(environment.serverConfig),
+ );
+ 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, persist, submit } = useProjectScriptSettings(
+ targets.flatMap(({ environmentId, serverConfig }) =>
+ serverConfig
+ ? [
+ {
+ environmentId,
+ settings: serverConfig.settings,
+ keybindings: serverConfig.keybindings,
+ supportsProjectDefaults: true,
+ },
+ ]
+ : [],
+ ),
+ );
+
+ 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 0000000000..8e45ec27d3
--- /dev/null
+++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
@@ -0,0 +1,489 @@
+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 { 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 {
+ outdatedProjectDefaultsNotice,
+ patchRequiresProjectDefaults,
+ settingRequiresProjectDefaults,
+ supportsProjectDefaults,
+} from "./ProjectSettingsPanel.logic";
+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 updateSettings = useAtomCommand(
+ serverEnvironment.updateSettings,
+ "project defaults update",
+ );
+ const savingRef = useRef(new Set());
+ const [saving, setSaving] = useState>(new Set());
+ const scoped = environments.filter(
+ (environment) => environmentId === null || environment.environmentId === environmentId,
+ );
+ const targets = scoped.filter(
+ (environment) =>
+ environment.connection.phase === "connected" && environment.serverConfig !== null,
+ );
+ // Older servers still save the workspace and browser access defaults, but drop the rest.
+ const currentTargets = targets.filter((target) => supportsProjectDefaults(target.serverConfig));
+ const outdatedTargets = targets.filter((target) => !supportsProjectDefaults(target.serverConfig));
+ const writeTargets = (patch: ServerSettingsPatch) =>
+ patchRequiresProjectDefaults(patch) ? currentTargets : targets;
+ const representativeTargets = currentTargets.length > 0 ? currentTargets : targets;
+ const representative =
+ representativeTargets.find(
+ (environment) => environment.environmentId === primaryEnvironmentId,
+ ) ?? representativeTargets[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 = currentTargets.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 = (key: keyof ServerSettingsPatch) =>
+ (settingRequiresProjectDefaults(key) ? currentTargets : targets).length === 0 ||
+ saving.has(key);
+ const mixedAutoPull = currentTargets.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 currentTargets) {
+ 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) {
+ const keys = Object.keys(patch);
+ const saveTargets = writeTargets(patch);
+ if (saveTargets.length === 0 || keys.some((key) => savingRef.current.has(key))) 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;
+ }
+ for (const key of keys) savingRef.current.add(key);
+ setSaving(new Set(savingRef.current));
+ try {
+ const results = await Promise.all(
+ saveTargets.map((target) =>
+ updateSettings({ environmentId: target.environmentId, input: { patch } }),
+ ),
+ );
+ const failedTargets = saveTargets.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 {
+ for (const key of keys) savingRef.current.delete(key);
+ setSaving(new Set(savingRef.current));
+ }
+ }
+
+ 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}
+ {outdatedTargets.length > 0 ? (
+
+ {outdatedProjectDefaultsNotice(
+ environmentId === null ? outdatedTargets.map((target) => target.label) : null,
+ )}
+
+ ) : null}
+
setModel(null)}
+ />
+ ) : null
+ }
+ control={
+ selection && activeEntry ? (
+
+ ) : (
+ 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.logic.test.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts
index 8a72b3510c..8d5fd7eb0b 100644
--- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts
+++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts
@@ -1,6 +1,15 @@
+import { EnvironmentId, ProjectId, type ProjectScript } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
-import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
+import {
+ outdatedProjectDefaultsNotice,
+ patchRequiresProjectDefaults,
+ planProjectOverrideWrites,
+ projectGroupTitleNeedsUpdate,
+ resolveProjectScriptsWrite,
+ settingRequiresProjectDefaults,
+ supportsProjectDefaults,
+} from "./ProjectSettingsPanel.logic";
describe("projectGroupTitleNeedsUpdate", () => {
it("updates divergent member titles even when the next title is the derived group label", () => {
@@ -21,3 +30,156 @@ describe("projectGroupTitleNeedsUpdate", () => {
);
});
});
+
+const currentServer = { environment: { capabilities: { projectDefaults: true } } };
+const olderServer = { environment: { capabilities: {} } };
+const action: ProjectScript = {
+ id: "check",
+ name: "Check",
+ command: "npm test",
+ icon: "play",
+ runOnWorktreeCreate: false,
+};
+
+describe("project defaults support", () => {
+ it("trusts only the server capability", () => {
+ expect(supportsProjectDefaults(currentServer)).toBe(true);
+ expect(supportsProjectDefaults(olderServer)).toBe(false);
+ expect(supportsProjectDefaults(null)).toBe(false);
+ });
+
+ it("lets older servers keep the workspace and browser access defaults they already saved", () => {
+ expect(patchRequiresProjectDefaults({ defaultThreadEnvMode: "worktree" })).toBe(false);
+ expect(patchRequiresProjectDefaults({ enableAgentBrowserAccess: false })).toBe(false);
+ expect(patchRequiresProjectDefaults({ defaultModelSelection: null })).toBe(true);
+ expect(patchRequiresProjectDefaults({ defaultAutoPull: true })).toBe(true);
+ expect(patchRequiresProjectDefaults({ defaultProjectScripts: [] })).toBe(true);
+ expect(settingRequiresProjectDefaults("defaultThreadEnvMode")).toBe(false);
+ expect(settingRequiresProjectDefaults("projectAutoPullOverrides")).toBe(true);
+ });
+
+ it("names the machines an older server keeps from changing", () => {
+ expect(outdatedProjectDefaultsNotice(["Office", "Laptop"])).toBe(
+ "Update Office, Laptop to change the default model, automatic pull, and actions there. Workspace and browser access still apply.",
+ );
+ expect(outdatedProjectDefaultsNotice(null)).toBe(
+ "Update this machine to change its default model, automatic pull, and actions.",
+ );
+ });
+});
+
+describe("resolveProjectScriptsWrite", () => {
+ const projectId = ProjectId.make("project-actions");
+
+ it("saves checkout actions as settings overrides on current servers", () => {
+ expect(
+ resolveProjectScriptsWrite({
+ supportsProjectDefaults: true,
+ projectId,
+ nextScripts: [action],
+ }),
+ ).toEqual({ kind: "settings", patch: { projectScriptOverrides: { [projectId]: [action] } } });
+ expect(
+ resolveProjectScriptsWrite({
+ supportsProjectDefaults: true,
+ projectId: null,
+ nextScripts: [],
+ }),
+ ).toEqual({ kind: "settings", patch: { defaultProjectScripts: [] } });
+ });
+
+ it("falls back to the project record on older servers", () => {
+ expect(
+ resolveProjectScriptsWrite({
+ supportsProjectDefaults: false,
+ projectId,
+ nextScripts: [action],
+ }),
+ ).toEqual({ kind: "project", projectId, scripts: [action] });
+ expect(
+ resolveProjectScriptsWrite({ supportsProjectDefaults: false, projectId, nextScripts: null }),
+ ).toEqual({ kind: "project", projectId, scripts: [] });
+ });
+
+ it("has nowhere to save machine default actions on older servers", () => {
+ expect(
+ resolveProjectScriptsWrite({
+ supportsProjectDefaults: false,
+ projectId: null,
+ nextScripts: [action],
+ }),
+ ).toEqual({ kind: "unsupported" });
+ });
+});
+
+describe("planProjectOverrideWrites", () => {
+ const current = EnvironmentId.make("environment-current");
+ const older = EnvironmentId.make("environment-older");
+ const members = [
+ { environmentId: current, id: ProjectId.make("project-current") },
+ { environmentId: older, id: ProjectId.make("project-older") },
+ ];
+ const supports = (environmentId: EnvironmentId) => environmentId === current;
+
+ it("saves automatic pull on the project record for older servers", () => {
+ expect(
+ planProjectOverrideWrites({
+ key: "projectAutoPullOverrides",
+ enabled: true,
+ members,
+ supportsProjectDefaults: supports,
+ }),
+ ).toEqual([
+ { kind: "project", member: members[1], autoPull: true },
+ {
+ kind: "settings",
+ environmentId: current,
+ patch: { projectAutoPullOverrides: { "project-current": true } },
+ },
+ ]);
+ });
+
+ it("resets automatic pull by clearing opt-ins and overrides", () => {
+ expect(
+ planProjectOverrideWrites({
+ key: "projectAutoPullOverrides",
+ enabled: undefined,
+ members,
+ supportsProjectDefaults: supports,
+ }),
+ ).toEqual([
+ { kind: "project", member: members[0], autoPull: false },
+ { kind: "project", member: members[1], autoPull: false },
+ {
+ kind: "settings",
+ environmentId: current,
+ patch: { projectAutoPullOverrides: { "project-current": null } },
+ },
+ ]);
+ });
+
+ it("refuses a browser access override that an older server would drop", () => {
+ expect(
+ planProjectOverrideWrites({
+ key: "projectAgentBrowserAccessOverrides",
+ enabled: false,
+ members,
+ supportsProjectDefaults: supports,
+ }),
+ ).toBeNull();
+ expect(
+ planProjectOverrideWrites({
+ key: "projectAgentBrowserAccessOverrides",
+ enabled: false,
+ members: [members[0]!],
+ supportsProjectDefaults: supports,
+ }),
+ ).toEqual([
+ {
+ kind: "settings",
+ environmentId: current,
+ patch: { projectAgentBrowserAccessOverrides: { "project-current": false } },
+ },
+ ]);
+ });
+});
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts
index 17ff824099..570937bbd3 100644
--- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts
+++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts
@@ -1,3 +1,11 @@
+import type {
+ EnvironmentId,
+ ProjectId,
+ ProjectScript,
+ ServerConfig,
+ ServerSettingsPatch,
+} from "@t3tools/contracts";
+
export function projectGroupTitleNeedsUpdate(
memberTitles: ReadonlyArray,
nextTitle: string,
@@ -5,3 +13,133 @@ export function projectGroupTitleNeedsUpdate(
): boolean {
return wasEdited && memberTitles.some((title) => title !== nextTitle);
}
+
+const PROJECT_DEFAULTS_KEYS = [
+ "defaultModelSelection",
+ "defaultProjectScripts",
+ "defaultAutoPull",
+ "projectScriptOverrides",
+ "projectAutoPullOverrides",
+ "projectAgentBrowserAccessOverrides",
+] as const satisfies ReadonlyArray;
+
+/**
+ * Whether a server keeps machine project defaults and per-project overrides.
+ * Older servers strip those patch keys without an error, and clients decode
+ * the missing settings as defaults, so only the capability can tell.
+ */
+export function supportsProjectDefaults(
+ serverConfig:
+ | {
+ readonly environment: {
+ readonly capabilities: Pick<
+ ServerConfig["environment"]["capabilities"],
+ "projectDefaults"
+ >;
+ };
+ }
+ | null
+ | undefined,
+): boolean {
+ return serverConfig?.environment.capabilities.projectDefaults === true;
+}
+
+/** Workspace and browser access defaults predate project defaults; the rest need the capability. */
+export function settingRequiresProjectDefaults(key: keyof ServerSettingsPatch): boolean {
+ const keys: ReadonlyArray = PROJECT_DEFAULTS_KEYS;
+ return keys.includes(key);
+}
+
+export function patchRequiresProjectDefaults(patch: ServerSettingsPatch): boolean {
+ return PROJECT_DEFAULTS_KEYS.some((key) => patch[key] !== undefined);
+}
+
+/** Names the machines to update, or speaks about the one selected machine when `labels` is null. */
+export function outdatedProjectDefaultsNotice(labels: ReadonlyArray | null): string {
+ return labels === null
+ ? "Update this machine to change its default model, automatic pull, and actions."
+ : `Update ${labels.join(", ")} to change the default model, automatic pull, and actions there. Workspace and browser access still apply.`;
+}
+
+export type ProjectScriptsWrite =
+ | { readonly kind: "settings"; readonly patch: ServerSettingsPatch }
+ | {
+ readonly kind: "project";
+ readonly projectId: ProjectId;
+ readonly scripts: ReadonlyArray;
+ }
+ | { readonly kind: "unsupported" };
+
+/**
+ * Where a checkout's actions (or a machine's default actions, when `projectId`
+ * is null) are saved. `nextScripts` null resets a checkout to the machine defaults.
+ */
+export function resolveProjectScriptsWrite(input: {
+ readonly supportsProjectDefaults: boolean;
+ readonly projectId: ProjectId | null;
+ readonly nextScripts: ReadonlyArray | null;
+}): ProjectScriptsWrite {
+ if (input.supportsProjectDefaults) {
+ return {
+ kind: "settings",
+ patch:
+ input.projectId === null
+ ? { defaultProjectScripts: input.nextScripts ?? [] }
+ : { projectScriptOverrides: { [input.projectId]: input.nextScripts } },
+ };
+ }
+ if (input.projectId === null) return { kind: "unsupported" };
+ // Older servers keep actions on the project and have no machine defaults to inherit.
+ return { kind: "project", projectId: input.projectId, scripts: input.nextScripts ?? [] };
+}
+
+export type ProjectOverrideWrite =
+ | {
+ readonly kind: "settings";
+ readonly environmentId: EnvironmentId;
+ readonly patch: ServerSettingsPatch;
+ }
+ | { readonly kind: "project"; readonly member: Member; readonly autoPull: boolean };
+
+/**
+ * Writes that set (or, for `undefined`, reset) a per-project boolean on every
+ * checkout in a group. Older servers keep automatic pull on the project record
+ * and cannot override browser access, so that plan is null.
+ */
+export function planProjectOverrideWrites<
+ Member extends { readonly environmentId: EnvironmentId; readonly id: ProjectId },
+>(input: {
+ readonly key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides";
+ readonly enabled: boolean | undefined;
+ readonly members: ReadonlyArray;
+ readonly supportsProjectDefaults: (environmentId: EnvironmentId) => boolean;
+}): ReadonlyArray> | null {
+ const autoPull = input.key === "projectAutoPullOverrides";
+ const supported = input.members.filter((member) =>
+ input.supportsProjectDefaults(member.environmentId),
+ );
+ if (!autoPull && supported.length < input.members.length) return null;
+ const projectWrites = input.members.flatMap((member): ProjectOverrideWrite[] => {
+ if (!autoPull) return [];
+ if (!input.supportsProjectDefaults(member.environmentId)) {
+ return [{ kind: "project", member, autoPull: input.enabled ?? false }];
+ }
+ // Resetting also clears an automatic pull opt-in saved before overrides existed.
+ return input.enabled === undefined ? [{ kind: "project", member, autoPull: false }] : [];
+ });
+ const environmentIds = [...new Set(supported.map((member) => member.environmentId))];
+ const settingsWrites = environmentIds.map(
+ (environmentId): ProjectOverrideWrite => ({
+ kind: "settings",
+ environmentId,
+ patch: {
+ [input.key]: Object.fromEntries(
+ supported
+ .filter((member) => member.environmentId === environmentId)
+ .map((member) => [member.id, input.enabled ?? null]),
+ ),
+ },
+ }),
+ );
+ return [...projectWrites, ...settingsWrites];
+}
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx
index b75b88f853..fe723a15cf 100644
--- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx
@@ -12,52 +12,54 @@ import {
deriveProjectGroupingOverrideKey,
selectProjectGroupingSettings,
} from "../../logicalProject";
-import type {
- ContextMenuItem,
- ModelSelection,
- ProjectIconOverride,
- ProviderDriverKind,
- SidebarProjectGroupingMode,
- T3ProjectFileScript,
- ThreadEnvMode,
+import {
+ type EnvironmentId,
+ type ModelSelection,
+ type ProjectIconOverride,
+ type ProjectId,
+ type ProjectScript,
+ type ResolvedKeybindingsConfig,
+ type ServerSettings,
+ type ProviderDriverKind,
+ type PullRequestMergeMethod,
+ type SidebarProjectGroupingMode,
+ type T3ProjectFileScript,
+ type ThreadEnvMode,
} from "@t3tools/contracts";
import { resolveEnvModeLabel } from "../BranchToolbar.logic";
import { createModelSelection } from "@t3tools/shared/model";
+import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
+import {
+ projectScriptsInheritDefaults,
+ resolveProjectScripts,
+} from "@t3tools/shared/projectScripts";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
-import { useCanGoBack, useNavigate } from "@tanstack/react-router";
+import { useNavigate } from "@tanstack/react-router";
+import * as Equal from "effect/Equal";
import * as Cause from "effect/Cause";
-import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";
-import {
- lazy,
- Suspense,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- type MouseEvent as ReactMouseEvent,
-} from "react";
+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";
import {
useClientSettings,
useEnvironmentSettings,
useUpdateClientSettings,
- usePrimarySettings,
} from "../../hooks/useSettings";
-import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
-import { shortcutLabelForCommand } from "../../keybindings";
-import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";
-import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
-import { readLocalApi } from "../../localApi";
+import { ProjectActionsList } from "./ProjectActionsList";
+import { isElectron } from "../../env";
+import {
+ decodeProjectScriptKeybindingRule,
+ keybindingValueForCommand,
+} from "../../lib/projectScriptKeybindings";
import {
buildProjectScript,
commandForProjectScript,
nextProjectScriptId,
} from "../../projectScripts";
-import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings";
+import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
+import { readLocalApi } from "../../localApi";
import {
applyProviderInstanceSettings,
deriveProviderInstanceEntries,
@@ -78,6 +80,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
import { TraitsPicker } from "../chat/TraitsPicker";
import { ProjectFavicon } from "../ProjectFavicon";
+import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
import {
EMPTY_PROJECT_SCRIPT_INPUT,
editorRequestForScript,
@@ -98,16 +101,8 @@ import {
MenuTrigger,
} from "../ui/menu";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
-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,
@@ -119,7 +114,15 @@ import {
canPickExternalProjectFavicon,
ProjectFaviconPickerDialog,
} from "./ProjectFaviconPickerDialog";
-import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
+import {
+ planProjectOverrideWrites,
+ projectGroupTitleNeedsUpdate,
+ resolveProjectScriptsWrite,
+ supportsProjectDefaults,
+} from "./ProjectSettingsPanel.logic";
+
+const PROJECT_BROWSER_ACCESS_UPDATE_HINT =
+ "Update every environment in this project group to override agent browser access.";
const ProjectIconPickerDialog = lazy(() =>
import("./ProjectIconPickerDialog").then((module) => ({
@@ -127,14 +130,14 @@ const ProjectIconPickerDialog = lazy(() =>
})),
);
-const PROJECT_GROUPING_MODE_LABELS: Record = {
+export const PROJECT_GROUPING_MODE_LABELS: Record = {
repository: "Group by repository",
repository_path: "Group by repository path",
separate: "Keep separate",
};
/** Logical project groups for the settings page, sorted by display name. */
-function useSettingsProjectGroups(): SidebarProjectSnapshot[] {
+export function useSettingsProjectGroups(): SidebarProjectSnapshot[] {
const projects = useProjects();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const primaryEnvironmentId = usePrimaryEnvironmentId();
@@ -162,132 +165,59 @@ 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
- )}
-
-
- );
-}
-
-function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
+export function ProjectSettingsPanel({
+ projectKey,
+ environmentId = null,
+}: {
+ projectKey: string;
+ environmentId?: EnvironmentId | null;
+}) {
const groups = useSettingsProjectGroups();
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)),
);
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, members.length, environmentId]);
if (!selected) {
return (
@@ -298,17 +228,197 @@ function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
);
}
- return ;
+ 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 reportScriptFailure(result: AtomCommandResult) {
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add({
+ type: "error",
+ title: "Failed to save project actions",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ }
+ return mapAtomCommandResult(result, () => undefined);
+}
+
+export function useProjectScriptSettings(
+ targets: readonly {
+ environmentId: EnvironmentId;
+ settings: ServerSettings;
+ keybindings: ResolvedKeybindingsConfig;
+ supportsProjectDefaults: boolean;
+ project?: { id: ProjectId; scripts: readonly ProjectScript[] };
+ }[],
+) {
+ const projects = useProjects();
+ const [saving, setSaving] = useState(false);
+ const savingRef = useRef(false);
+ const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update");
+ const updateProject = useAtomCommand(projectEnvironment.update, "project actions update");
+ const upsertKeybinding = useAtomCommand(
+ serverEnvironment.upsertKeybinding,
+ "action shortcut update",
+ );
+ const removeKeybinding = useAtomCommand(
+ serverEnvironment.removeKeybinding,
+ "action shortcut removal",
+ );
+
+ async function persist(
+ transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null,
+ scriptId?: string,
+ keybinding?: string | null,
+ ): Promise> {
+ if (savingRef.current || targets.length === 0) {
+ const message = "No available machine, or another action change is saving.";
+ toastManager.add({ type: "error", title: "Actions not saved", description: message });
+ return AsyncResult.failure(Cause.fail(new Error(message)));
+ }
+ savingRef.current = true;
+ setSaving(true);
+ try {
+ for (const target of targets) {
+ const { environmentId, settings, keybindings, project } = target;
+ const current = project
+ ? resolveProjectScripts(settings, project)
+ : settings.defaultProjectScripts;
+ const nextScripts = transform(current);
+ const effectiveScripts = nextScripts ?? settings.defaultProjectScripts;
+ const write = resolveProjectScriptsWrite({
+ supportsProjectDefaults: target.supportsProjectDefaults,
+ projectId: project?.id ?? null,
+ nextScripts,
+ });
+ if (write.kind === "unsupported") {
+ const message = "Update this machine to save default actions.";
+ toastManager.add({ type: "error", title: "Actions not saved", description: message });
+ return AsyncResult.failure(Cause.fail(new Error(message)));
+ }
+ const result =
+ write.kind === "settings"
+ ? await updateSettings({ environmentId, input: { patch: write.patch } })
+ : await updateProject({
+ environmentId,
+ input: { projectId: write.projectId, scripts: write.scripts },
+ });
+ if (result._tag === "Failure") return reportScriptFailure(result);
+ if (!isElectron) continue;
+ const changedIds = scriptId
+ ? [scriptId]
+ : current
+ .filter((script) => !effectiveScripts.some((next) => next.id === script.id))
+ .map((script) => script.id);
+ for (const id of changedIds) {
+ const command = commandForProjectScript(id);
+ const previousValue = keybindingValueForCommand(keybindings, command);
+ const previous = previousValue
+ ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command })
+ : null;
+ const next = decodeProjectScriptKeybindingRule({ keybinding, command });
+ const retainedElsewhere =
+ !nextScripts?.some((script) => script.id === id) &&
+ ((project && settings.defaultProjectScripts.some((script) => script.id === id)) ||
+ Object.entries(settings.projectScriptOverrides).some(
+ ([projectId, scripts]) =>
+ projectId !== project?.id && scripts?.some((script) => script.id === id),
+ ) ||
+ projects.some(
+ (other) =>
+ other.environmentId === environmentId &&
+ other.id !== project?.id &&
+ (project ? resolveProjectScripts(settings, other) : other.scripts).some(
+ (script) => script.id === id,
+ ),
+ ));
+ const bindingResult = next
+ ? await upsertKeybinding({
+ environmentId,
+ input:
+ previous && previous.key !== next.key ? { ...next, replace: previous } : next,
+ })
+ : previous && !retainedElsewhere
+ ? await removeKeybinding({ environmentId, input: previous })
+ : null;
+ if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult);
+ }
+ }
+ return AsyncResult.success(undefined);
+ } finally {
+ savingRef.current = false;
+ setSaving(false);
+ }
+ }
+
+ function submit(scriptId: string | null, input: NewProjectScriptInput) {
+ const existingIds = [
+ ...projects.flatMap((project) => project.scripts.map((script) => script.id)),
+ ...targets.flatMap(({ settings, project }) =>
+ [
+ ...settings.defaultProjectScripts,
+ ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []),
+ ...(project?.scripts ?? []),
+ ].map((script) => script.id),
+ ),
+ ];
+ const id = scriptId ?? nextProjectScriptId(input.name, existingIds);
+ const next = buildProjectScript(id, input);
+ return persist(
+ (current) => {
+ const updated = current.map((script) =>
+ script.id === id
+ ? next
+ : input.runOnWorktreeCreate
+ ? { ...script, runOnWorktreeCreate: false }
+ : script,
+ );
+ return scriptId === null ? [...updated, next] : updated;
+ },
+ id,
+ input.keybinding,
+ );
+ }
+
+ return { saving, persist, submit };
}
-function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
+function ProjectDetail({
+ group,
+ hasOtherMembers,
+}: {
+ group: SidebarProjectSnapshot;
+ hasOtherMembers: boolean;
+}) {
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,
+ (member) => environmentById.get(member.environmentId)?.serverConfig != null,
) ?? 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.
@@ -320,28 +430,110 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const threads = useThreadShells();
const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
- const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
- const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
- reportFailure: false,
+ const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting");
+ const [savingBrowserAccess, setSavingBrowserAccess] = useState(false);
+ const savingBrowserAccessRef = useRef(false);
+ const browserOverrides = group.memberProjects.map(
+ (member) =>
+ environmentById.get(member.environmentId)?.serverConfig?.settings
+ .projectAgentBrowserAccessOverrides[member.id],
+ );
+ const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id];
+ const browserMixed = group.memberProjects.some((member, index) => {
+ const settings = environmentById.get(member.environmentId)?.serverConfig?.settings;
+ if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false;
+ return (
+ browserOverrides[index] !== browserOverride ||
+ (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !==
+ (browserOverride ?? projectSettings.enableAgentBrowserAccess)
+ );
});
- const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, {
- reportFailure: false,
+ // An offline checkout does not block the row; saving asks for it to connect.
+ const browserOverridesSupported = group.memberProjects.every((member) => {
+ const config = environmentById.get(member.environmentId)?.serverConfig;
+ return config == null || supportsProjectDefaults(config);
});
+ const setBooleanOverride = async (
+ key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides",
+ enabled: boolean | undefined,
+ ) => {
+ if (savingBrowserAccessRef.current) return;
+ savingBrowserAccessRef.current = true;
+ setSavingBrowserAccess(true);
+ try {
+ const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId));
+ for (const environmentId of environmentIds) {
+ const environment = environmentById.get(environmentId);
+ if (!environment?.serverConfig || environment.connection.phase !== "connected") {
+ toastManager.add({
+ type: "warning",
+ title: "Setting not saved",
+ description: `Connect ${environment?.label ?? "this machine"} and try again.`,
+ });
+ return;
+ }
+ }
+ const writes = planProjectOverrideWrites({
+ key,
+ enabled,
+ members: group.memberProjects,
+ supportsProjectDefaults: (environmentId) =>
+ supportsProjectDefaults(environmentById.get(environmentId)?.serverConfig),
+ });
+ if (writes === null) {
+ toastManager.add({
+ type: "warning",
+ title: "Setting not saved",
+ description: PROJECT_BROWSER_ACCESS_UPDATE_HINT,
+ });
+ return;
+ }
+ for (const write of writes) {
+ const environmentId =
+ write.kind === "settings" ? write.environmentId : write.member.environmentId;
+ const result =
+ write.kind === "settings"
+ ? mapAtomCommandResult(
+ await updateServerSettings({ environmentId, input: { patch: write.patch } }),
+ () => undefined,
+ )
+ : mapAtomCommandResult(
+ await updateProject({
+ environmentId,
+ input: { projectId: write.member.id, autoPull: write.autoPull },
+ }),
+ () => undefined,
+ );
+ if (result._tag === "Failure") {
+ reportFailure(
+ `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`,
+ result,
+ );
+ return;
+ }
+ }
+ } finally {
+ savingBrowserAccessRef.current = false;
+ setSavingBrowserAccess(false);
+ }
+ };
+ const setBrowserAccess = (enabled: boolean | undefined) =>
+ setBooleanOverride("projectAgentBrowserAccessOverrides", enabled);
+ const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
const projectNameEditedRef = useRef(false);
- const { 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 mergeMethodOverrides = useClientSettings(
+ (settings) => settings.pullRequestMergeMethodOverrides,
+ );
+ const projectMergeMethod = mergeMethodOverrides[group.projectKey];
+ const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => {
+ const nextOverrides = { ...mergeMethodOverrides };
+ if (method === null) {
+ delete nextOverrides[group.projectKey];
+ } else {
+ nextOverrides[group.projectKey] = method;
+ }
+ updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides });
+ };
const faviconPath = representative.faviconPath ?? null;
const projectIcon = representative.projectIcon ?? null;
@@ -359,14 +551,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);
@@ -441,7 +625,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
// ----- default model -----
const storedSelection = representative.defaultModelSelection;
- const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, 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) ||
+ (config !== null &&
+ config !== undefined &&
+ environmentById.get(representative.environmentId)?.serverConfig != null &&
+ JSON.stringify(
+ resolveDefaultProviderModelSelection(
+ config.providers,
+ member.defaultModelSelection ?? config.settings.defaultModelSelection,
+ ),
+ ) !== JSON.stringify(resolvedSelection))
+ );
+ });
const resolvedInstanceId = resolvedSelection?.instanceId ?? null;
const resolvedModel = resolvedSelection?.model ?? null;
const instanceEntries = useMemo(
@@ -465,14 +667,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(
@@ -482,12 +715,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);
@@ -517,27 +762,47 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
);
// ----- checkout selection and scripts -----
- const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey);
- const selectedCheckout =
- group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ??
- representative;
+ const hasMultipleCheckouts = group.memberProjects.length > 1;
+ const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null);
+ const selectedCheckoutMatch = group.memberProjects.find(
+ (member) => member.physicalProjectKey === selectedCheckoutKey,
+ );
+ const selectedCheckout = selectedCheckoutMatch ?? representative;
const selectedServerConfig = useAtomValue(
serverEnvironment.configValueAtom(selectedCheckout.environmentId),
);
const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
- const scripts = selectedCheckout.scripts;
+ const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId);
+ const scripts = resolveProjectScripts(scriptSettings, selectedCheckout);
+ const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout);
+ // Older servers keep actions on the checkout itself, with no machine defaults to inherit.
+ const scriptDefaultsSupported = supportsProjectDefaults(selectedServerConfig);
const [editorRequest, setEditorRequest] = useState(null);
- // 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.
- const [isSavingScripts, setIsSavingScripts] = useState(false);
- const savingScriptsRef = useRef(false);
+ const {
+ saving: isSavingScripts,
+ persist: persistScripts,
+ submit: submitScript,
+ } = useProjectScriptSettings(
+ // Until the checkout's server reports its capabilities there is no telling where actions are kept.
+ selectedServerConfig
+ ? [
+ {
+ environmentId: selectedCheckout.environmentId,
+ settings: scriptSettings,
+ keybindings,
+ supportsProjectDefaults: scriptDefaultsSupported,
+ project: selectedCheckout,
+ },
+ ]
+ : [],
+ );
const t3File = useT3ProjectFileState(
selectedCheckout.environmentId,
selectedCheckout.workspaceRoot,
);
// What the "Default" option resolves to while no override is set: the
// repo's t3.json value when present, otherwise the global setting.
- const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode;
+ const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode;
const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global";
const importableScripts = useMemo(
() =>
@@ -552,135 +817,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
[scripts, t3File.scripts],
);
- const persistScripts = useCallback(
- async (
- nextScripts: ReadonlyArray>,
- keybinding: string | null | undefined,
- keybindingCommand: ReturnType,
- ): Promise> => {
- if (savingScriptsRef.current) {
- return AsyncResult.failure(
- Cause.fail(new Error("Another script change is still saving. Try again.")),
- );
- }
- 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 = keybindingValueForCommand(keybindings, keybindingCommand);
- const updateResult = mapAtomCommandResult(
- await updateProject({
- environmentId: selectedCheckout.environmentId,
- input: { projectId: selectedCheckout.id, scripts: nextScripts },
- }),
- () => undefined,
- );
- if (updateResult._tag === "Failure") {
- reportFailure("Failed to save scripts", updateResult);
- 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;
- }
- }
- } else if (previousTarget) {
- 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;
- }
- }
- }
- return updateResult;
- } finally {
- savingScriptsRef.current = false;
- setIsSavingScripts(false);
- }
- },
- [
- keybindings,
- removeKeybinding,
- reportFailure,
- selectedCheckout.environmentId,
- selectedCheckout.id,
- updateProject,
- upsertKeybinding,
- ],
- );
-
- const submitScript = useCallback(
- async (
- scriptId: string | null,
- input: NewProjectScriptInput,
- ): Promise> => {
- if (scriptId === null) {
- const nextId = nextProjectScriptId(
- input.name,
- scripts.map((script) => script.id),
- );
- const nextScript = buildProjectScript(nextId, input);
- const nextScripts = input.runOnWorktreeCreate
- ? [
- ...scripts.map((script) =>
- script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,
- ),
- nextScript,
- ]
- : [...scripts, nextScript];
- return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId));
- }
-
- const updatedScript = buildProjectScript(scriptId, input);
- const nextScripts = scripts.map((script) =>
- script.id === scriptId
- ? updatedScript
- : input.runOnWorktreeCreate
- ? { ...script, runOnWorktreeCreate: false }
- : script,
- );
- return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId));
- },
- [persistScripts, scripts],
- );
-
- const deleteScript = useCallback(
- (scriptId: string) => {
- const nextScripts = scripts.filter((script) => script.id !== scriptId);
- void persistScripts(nextScripts, null, commandForProjectScript(scriptId));
- },
- [persistScripts, scripts],
- );
+ const deleteScript = (scriptId: string) =>
+ void persistScripts(
+ (current) => current.filter((script) => script.id !== scriptId),
+ scriptId,
+ null,
+ );
const importFileScript = useCallback(
async (fileScript: T3ProjectFileScript) => {
@@ -703,7 +845,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
});
}
},
- [submitScript],
+ [submitScript, setEditorRequest],
);
// ----- checkouts -----
@@ -731,14 +873,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}`,
@@ -752,7 +895,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.",
@@ -794,33 +937,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
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,
threads,
],
);
- const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0;
const selectedCheckoutGrouping =
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 (
<>
-
-
+
+
-
+
- setSelectedCheckoutKey(String(value))}
- >
-
- {selectedCheckoutLabel}
-
-
- {group.memberProjects.map((member) => (
-
- {member.environmentLabel ?? "This machine"} · {member.workspaceRoot}
-
- ))}
-
-
- }
- >
-
-
-
-
- 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) => (
+
+
+ {checkoutLabel(member)}
+
+
+ ))}
+
+
+ }
+ />
+ ) : null}
updateGroupingPreference(selectedCheckout, "inherit")}
+ />
+ ) : null
+ }
control={
+ {scriptDefaultsSupported && !scriptsInherited ? (
+ void persistScripts(() => null)}
+ />
+ ) : null}
{importableScripts.length > 0 ? (
- {scripts.length === 0 ? (
-
- No actions configured for this checkout.
-
- ) : (
- 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}
-
- >
- }
- />
- );
- })
- )}
+ setEditorRequest(editorRequestForScript(script, keybindings))}
+ />
{t3File.status === "invalid" ? (
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
new file mode 100644
index 0000000000..acc7326ac8
--- /dev/null
+++ b/apps/web/src/components/settings/ProjectsSettings.tsx
@@ -0,0 +1,168 @@
+import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
+import { ChevronDownIcon, FolderIcon } from "lucide-react";
+import { type ReactNode, useState } 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 {
+ Combobox,
+ ComboboxEmpty,
+ ComboboxSearchInput,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxPopup,
+ ComboboxTrigger,
+} from "../ui/combobox";
+import { selectTriggerVariants } from "../ui/select";
+import { cn } from "../../lib/utils";
+import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel";
+import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
+
+function ScopePicker({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: "project" | "machine";
+ value: string | null;
+ options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>;
+ onChange: (value: string | null) => void;
+}) {
+ const [query, setQuery] = useState("");
+ const selected = options.find((option) => option.value === value);
+ const allIcon =
+ label === "project" ? : null;
+ const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options];
+ return (
+ item.value === (value ?? "all")) ?? null}
+ inputValue={query}
+ onInputValueChange={setQuery}
+ onOpenChange={() => setQuery("")}
+ onValueChange={(next) => {
+ if (next) onChange(next.value === "all" ? null : next.value);
+ }}
+ >
+
+
+ {value === null ? allIcon : selected?.icon}
+
+ {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)}
+
+
+
+
+
+
+ No matching {label}s.
+
+ {(item: (typeof items)[number]) => (
+
+ {item.icon}
+ {item.label}
+
+ )}
+
+
+
+ );
+}
+
+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 a96fd2f099..f8073d305a 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -2556,55 +2556,24 @@ export function GeneralSettingsPanel() {
- updateSettings({
- defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
- newWorktreesStartFromOrigin:
- DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
- })
- }
- />
- ) : null
- }
+ description="Choose the default model and workspace for all projects or a specific project."
control={
-
+ Project settings
+
}
/>
snapshot.projects.map(
- (project) =>
- [
- `${environmentId}:${project.id}`,
- {
- id: project.id,
- environmentId,
- name: project.title,
- cwd: project.workspaceRoot,
- faviconPath: project.faviconPath,
- projectIcon: project.projectIcon,
- },
- ] as const,
+ (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
),
),
);
@@ -3017,16 +2975,8 @@ export function ArchivedThreadsPanel() {
- }
+ title={project.title}
+ icon={}
>
{projectThreads.map((thread) => (
= {
"/settings/general": Settings2Icon,
"/settings/appearance": PaletteIcon,
+ "/settings/projects": PanelsTopLeftIcon,
"/settings/keybindings": KeyboardIcon,
"/settings/providers": BotIcon,
"/settings/integrations": BlocksIcon,
@@ -165,12 +167,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
setOpenMobile(false);
}
const targetId = item.targetId ?? item.id;
- if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) {
+ if (
+ item.to !== "/settings/projects" &&
+ pathname === item.to &&
+ currentHash.replace(/^#/, "") === targetId
+ ) {
scrollToSettingsTarget(targetId);
return;
}
void navigate({
to: item.to,
+ search: (previous) =>
+ item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
hash: targetId,
replace: true,
hashScrollIntoView: false,
diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx
index fc07cf2f7f..2466073407 100644
--- a/apps/web/src/components/settings/settingsLayout.tsx
+++ b/apps/web/src/components/settings/settingsLayout.tsx
@@ -281,7 +281,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,
+ )}
>
@@ -317,10 +321,12 @@ export function SettingsRow({
export function SettingResetButton({
label,
+ tooltip = "Reset to default",
disabled = false,
onClick,
}: {
label: string;
+ tooltip?: string;
disabled?: boolean;
onClick: () => void;
}) {
@@ -342,7 +348,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 3ebfdad2d0..e044d7e0a8 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,15 @@ 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",
+ "project name icon emoji favicon image pull request merge method squash rebase",
+ ],
+ },
{
id: "color-scheme",
title: "Color scheme",
@@ -239,14 +250,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 +353,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"],
},
{
@@ -503,6 +513,14 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/connections",
searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"],
},
+ {
+ id: "load-balancing",
+ title: "Load balancing",
+ to: "/settings/connections",
+ searchTerms: [
+ "automatic machine environment resources cpu memory capacity preference weight shared projects",
+ ],
+ },
{
id: "archive",
title: "Archived threads",
diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx
index f18bd9f7bd..6437f1fc87 100644
--- a/apps/web/src/components/ui/combobox.tsx
+++ b/apps/web/src/components/ui/combobox.tsx
@@ -1,7 +1,7 @@
"use client";
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
-import { ChevronsUpDownIcon, XIcon } from "lucide-react";
+import { ChevronsUpDownIcon, SearchIcon, XIcon } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
@@ -130,6 +130,27 @@ function ComboboxInput({
);
}
+function ComboboxSearchInput(props: React.ComponentProps) {
+ return (
+
+ );
+}
+
function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
return (
@@ -393,6 +414,7 @@ export {
Combobox,
ComboboxChipsInput,
ComboboxInput,
+ ComboboxSearchInput,
ComboboxTrigger,
ComboboxPopup,
ComboboxItem,
diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts
index b1c317eea6..5d9e079c77 100644
--- a/apps/web/src/composerDraftStore.test.ts
+++ b/apps/web/src/composerDraftStore.test.ts
@@ -1624,6 +1624,63 @@ describe("composerDraftStore project draft thread mapping", () => {
expect(file && composerFileNeedsReattach(file)).toBe(true);
});
+ it("rechecks balancing when an empty draft is remapped to another project member", () => {
+ const store = useComposerDraftStore.getState();
+ store.setProjectDraftThreadId(projectRef, draftId, {
+ threadId,
+ environmentSelection: "auto",
+ loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID,
+ });
+ store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId });
+ expect(store.getDraftThread(draftId)).toMatchObject({
+ environmentSelection: "auto",
+ loadBalancedEnvironmentId: null,
+ });
+ store.setDraftThreadContext(draftId, { loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID });
+ store.setDraftThreadContext(draftId, { projectRef });
+ expect(store.getDraftThread(draftId)).toMatchObject({
+ environmentSelection: "auto",
+ loadBalancedEnvironmentId: null,
+ });
+ });
+
+ it("does not opt a legacy branch choice into balancing when runtime mode changes", () => {
+ const store = useComposerDraftStore.getState();
+ store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/pinned" });
+ store.setDraftThreadContext(draftId, { runtimeMode: "full-access" });
+ expect(store.getDraftThread(draftId)?.environmentSelection).toBeUndefined();
+ expect(store.getDraftThread(draftId)?.branch).toBe("feature/pinned");
+ });
+
+ it("pins manual workspace choices and can return to automatic routing without losing the prompt", () => {
+ const store = useComposerDraftStore.getState();
+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
+ store.setPrompt(draftId, "keep this prompt");
+ store.setDraftThreadContext(draftId, {
+ projectRef: remoteProjectRef,
+ environmentSelection: "auto",
+ loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID,
+ });
+ expect(store.getDraftThread(draftId)).toMatchObject({
+ environmentId: OTHER_TEST_ENVIRONMENT_ID,
+ environmentSelection: "auto",
+ loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID,
+ });
+ store.setDraftThreadContext(draftId, { branch: "feature/pinned" });
+ expect(store.getDraftThread(draftId)?.environmentSelection).toBe("manual");
+ store.setDraftThreadContext(draftId, {
+ branch: null,
+ environmentSelection: "auto",
+ loadBalancedEnvironmentId: null,
+ });
+ expect(store.getDraftThread(draftId)).toMatchObject({
+ branch: null,
+ environmentSelection: "auto",
+ loadBalancedEnvironmentId: null,
+ });
+ expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt");
+ });
+
it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => {
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(projectRef, draftId, {
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index e48b49912c..29a94f0ef5 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -327,6 +327,8 @@ const PersistedDraftThreadState = Schema.Struct({
environmentId: Schema.String,
projectId: ProjectId,
logicalProjectKey: Schema.optionalKey(Schema.String),
+ environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])),
+ loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)),
createdAt: Schema.String,
runtimeMode: RuntimeMode,
interactionMode: ProviderInteractionMode,
@@ -451,6 +453,8 @@ export interface DraftSessionState {
environmentId: EnvironmentId;
projectId: ProjectId;
logicalProjectKey: string;
+ environmentSelection?: "auto" | "manual";
+ loadBalancedEnvironmentId?: EnvironmentId | null;
createdAt: string;
runtimeMode: RuntimeMode;
interactionMode: ProviderInteractionMode;
@@ -527,6 +531,8 @@ interface ComposerDraftStoreState {
startFromOrigin?: boolean;
runtimeMode?: RuntimeMode;
interactionMode?: ProviderInteractionMode;
+ environmentSelection?: "auto" | "manual";
+ loadBalancedEnvironmentId?: EnvironmentId | null;
},
) => void;
/** Creates or updates the draft session tracked for a concrete project ref. */
@@ -542,6 +548,8 @@ interface ComposerDraftStoreState {
startFromOrigin?: boolean;
runtimeMode?: RuntimeMode;
interactionMode?: ProviderInteractionMode;
+ environmentSelection?: "auto" | "manual";
+ loadBalancedEnvironmentId?: EnvironmentId | null;
},
) => void;
/** Updates mutable draft-session metadata without touching composer content. */
@@ -556,6 +564,8 @@ interface ComposerDraftStoreState {
startFromOrigin?: boolean;
runtimeMode?: RuntimeMode;
interactionMode?: ProviderInteractionMode;
+ environmentSelection?: "auto" | "manual";
+ loadBalancedEnvironmentId?: EnvironmentId | null;
},
) => void;
clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void;
@@ -1596,6 +1606,8 @@ function createDraftThreadState(
startFromOrigin?: boolean;
runtimeMode?: RuntimeMode;
interactionMode?: ProviderInteractionMode;
+ environmentSelection?: "auto" | "manual";
+ loadBalancedEnvironmentId?: EnvironmentId | null;
},
): DraftThreadState {
// A project change (including switching environments within a logical
@@ -1622,11 +1634,23 @@ function createDraftThreadState(
options?.startFromOrigin === undefined
? (existingThread?.startFromOrigin ?? false)
: options.startFromOrigin;
+ const environmentSelection =
+ options?.environmentSelection ?? existingThread?.environmentSelection;
return {
threadId,
environmentId: projectRef.environmentId,
projectId: projectRef.projectId,
logicalProjectKey,
+ ...(environmentSelection ? { environmentSelection } : {}),
+ ...(options?.loadBalancedEnvironmentId !== undefined
+ ? { loadBalancedEnvironmentId: options.loadBalancedEnvironmentId }
+ : existingThread?.loadBalancedEnvironmentId !== undefined
+ ? {
+ loadBalancedEnvironmentId: projectChanged
+ ? null
+ : existingThread.loadBalancedEnvironmentId,
+ }
+ : {}),
createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
interactionMode:
@@ -1661,6 +1685,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea
left.environmentId === right.environmentId &&
left.projectId === right.projectId &&
left.logicalProjectKey === right.logicalProjectKey &&
+ left.environmentSelection === right.environmentSelection &&
+ left.loadBalancedEnvironmentId === right.loadBalancedEnvironmentId &&
left.createdAt === right.createdAt &&
left.runtimeMode === right.runtimeMode &&
left.interactionMode === right.interactionMode &&
@@ -1816,6 +1842,16 @@ function normalizePersistedDraftThreads(
worktreePath: normalizedWorktreePath,
envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
startFromOrigin,
+ ...(candidateDraftThread.environmentSelection === "manual" ||
+ candidateDraftThread.environmentSelection === "auto"
+ ? { environmentSelection: candidateDraftThread.environmentSelection }
+ : {}),
+ ...(typeof candidateDraftThread.loadBalancedEnvironmentId === "string" &&
+ candidateDraftThread.loadBalancedEnvironmentId.length > 0
+ ? { loadBalancedEnvironmentId: candidateDraftThread.loadBalancedEnvironmentId }
+ : candidateDraftThread.loadBalancedEnvironmentId === null
+ ? { loadBalancedEnvironmentId: null }
+ : {}),
promotedTo,
};
}
@@ -2561,6 +2597,15 @@ function toHydratedDraftThreadState(
worktreePath: persistedDraftThread.worktreePath,
envMode: persistedDraftThread.envMode,
startFromOrigin: persistedDraftThread.startFromOrigin,
+ ...(persistedDraftThread.environmentSelection
+ ? { environmentSelection: persistedDraftThread.environmentSelection }
+ : {}),
+ ...(persistedDraftThread.loadBalancedEnvironmentId !== undefined
+ ? {
+ loadBalancedEnvironmentId:
+ persistedDraftThread.loadBalancedEnvironmentId as EnvironmentId | null,
+ }
+ : {}),
promotedTo: persistedDraftThread.promotedTo
? scopeThreadRef(
persistedDraftThread.promotedTo.environmentId as EnvironmentId,
@@ -2815,11 +2860,23 @@ const composerDraftStore = create()(
options.startFromOrigin === undefined
? existing.startFromOrigin
: options.startFromOrigin;
+ const environmentSelection =
+ options.environmentSelection ??
+ (options.branch != null || options.worktreePath != null
+ ? "manual"
+ : existing.environmentSelection);
const nextDraftThread: DraftThreadState = {
threadId: existing.threadId,
environmentId: nextProjectRef.environmentId,
projectId: nextProjectRef.projectId,
logicalProjectKey: existing.logicalProjectKey,
+ ...(environmentSelection ? { environmentSelection } : {}),
+ loadBalancedEnvironmentId:
+ options.loadBalancedEnvironmentId === undefined
+ ? projectChanged
+ ? null
+ : (existing.loadBalancedEnvironmentId ?? null)
+ : options.loadBalancedEnvironmentId,
createdAt:
options.createdAt === undefined
? existing.createdAt
@@ -2837,6 +2894,8 @@ const composerDraftStore = create()(
nextDraftThread.environmentId === existing.environmentId &&
nextDraftThread.projectId === existing.projectId &&
nextDraftThread.logicalProjectKey === existing.logicalProjectKey &&
+ nextDraftThread.environmentSelection === existing.environmentSelection &&
+ nextDraftThread.loadBalancedEnvironmentId === existing.loadBalancedEnvironmentId &&
nextDraftThread.createdAt === existing.createdAt &&
nextDraftThread.runtimeMode === existing.runtimeMode &&
nextDraftThread.interactionMode === existing.interactionMode &&
diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts
index 91b757f51e..afd503e63c 100644
--- a/apps/web/src/hooks/useHandleNewThread.test.ts
+++ b/apps/web/src/hooks/useHandleNewThread.test.ts
@@ -49,14 +49,31 @@ const testState = vi.hoisted(() => {
});
vi.mock("@effect/atom-react", () => ({
- useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }),
+ useAtomValue: (atom: unknown) =>
+ atom === "primary-settings"
+ ? { newWorktreesStartFromOrigin: false }
+ : new Map([
+ [
+ "environment-ssh",
+ {
+ settings: {
+ defaultThreadEnvMode: "local",
+ newWorktreesStartFromOrigin: false,
+ defaultModelSelection: null,
+ },
+ },
+ ],
+ ]),
}));
vi.mock("@t3tools/client-runtime/environment", () => ({
scopedProjectKey: () => "remote-project",
scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),
scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),
}));
-vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" }));
+vi.mock("@t3tools/contracts", () => ({
+ DEFAULT_RUNTIME_MODE: "default",
+ DEFAULT_SERVER_SETTINGS: {},
+}));
vi.mock("@t3tools/shared/threadEnvMode", () => ({
resolveDefaultThreadEnvMode: (input: {
readonly projectFile: "local" | "worktree" | null;
@@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({
useProjects: () => [],
useThread: () => null,
}));
-vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} }));
+vi.mock("../state/server", () => ({
+ environmentServerConfigsAtom: {},
+ primaryServerSettingsAtom: "primary-settings",
+}));
vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null }));
vi.mock("../uiStateStore", () => ({
legacyProjectCwdPreferenceKey: () => "remote-project",
diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts
index c26b25d131..78dfc1b13f 100644
--- a/apps/web/src/hooks/useHandleNewThread.ts
+++ b/apps/web/src/hooks/useHandleNewThread.ts
@@ -4,7 +4,12 @@ import {
scopeProjectRef,
scopeThreadRef,
} from "@t3tools/client-runtime/environment";
-import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";
+import {
+ DEFAULT_RUNTIME_MODE,
+ DEFAULT_SERVER_SETTINGS,
+ type ScopedProjectRef,
+ type ThreadId,
+} from "@t3tools/contracts";
import { useParams, useRouter } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
import {
@@ -30,7 +35,7 @@ import {
resolveNewThreadModelSelectionOverride,
} from "../lib/chatThreadActions";
import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
-import { primaryServerSettingsAtom } from "../state/server";
+import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server";
import { resolveThreadRouteTarget } from "../threadRoutes";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
import { useClientSettings } from "./useSettings";
@@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
}
export function useNewThreadHandler() {
- // New-thread defaults are a user preference, and the settings UI only ever
- // edits the primary environment's settings.json. Reading the target
- // environment's own settings here would silently reset remote projects to
- // the decoded defaults ("local" mode, current branch), since nothing can
- // set those values on a remote server.
+ const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom);
const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const router = useRouter();
@@ -83,6 +84,8 @@ export function useNewThreadHandler() {
// up again and finding whichever draft it happens to hold.
): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {
const projects = readProjects();
+ const targetServerSettings =
+ environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS;
const {
getComposerDraft,
getDraftSessionByLogicalProjectKey,
@@ -138,7 +141,8 @@ export function useNewThreadHandler() {
);
const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
resolveNewThreadModelSelectionOverride({
- projectDefaultSelection: project?.defaultModelSelection ?? null,
+ projectDefaultSelection:
+ project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null,
carrySelection: carryModelSelection,
carrySourceDraftId:
currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
@@ -157,7 +161,7 @@ export function useNewThreadHandler() {
project.workspaceRoot,
)
: null,
- globalDefault: primaryServerSettings.defaultThreadEnvMode,
+ globalDefault: targetServerSettings.defaultThreadEnvMode,
});
};
const logicalProjectKey = project
@@ -429,7 +433,13 @@ export function useNewThreadHandler() {
return { draftId, threadId };
})();
},
- [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router],
+ [
+ environmentServerConfigs,
+ getCurrentRouteTarget,
+ primaryServerSettings.newWorktreesStartFromOrigin,
+ projectGroupingSettings,
+ router,
+ ],
);
}
diff --git a/apps/web/src/hooks/useLoadBalancedEnvironment.ts b/apps/web/src/hooks/useLoadBalancedEnvironment.ts
new file mode 100644
index 0000000000..b0927ca66f
--- /dev/null
+++ b/apps/web/src/hooks/useLoadBalancedEnvironment.ts
@@ -0,0 +1,48 @@
+import { RegistryContext, useAtomValue } from "@effect/atom-react";
+import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing";
+import type { EnvironmentId } from "@t3tools/contracts";
+import { Atom } from "effect/unstable/reactivity";
+import { useCallback, useContext, useMemo } from "react";
+
+import { serverEnvironment } from "../state/server";
+
+/** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */
+export function useLoadBalancedEnvironment(
+ environmentIds: readonly EnvironmentId[],
+ weights: Readonly>,
+) {
+ const registry = useContext(RegistryContext);
+ const refresh = useCallback(
+ (ids: readonly EnvironmentId[]) => {
+ for (const environmentId of ids) {
+ registry.refresh(serverEnvironment.hostResources({ environmentId, input: {} }));
+ }
+ },
+ [registry],
+ );
+ const resourcesAtom = useMemo(
+ () =>
+ Atom.make((get) =>
+ environmentIds.map((environmentId) => {
+ const result = get(serverEnvironment.hostResources({ environmentId, input: {} }));
+ return {
+ environmentId,
+ resources: result._tag === "Success" ? result.value : null,
+ receivedAt: result._tag === "Success" ? result.timestamp : 0,
+ pending: result._tag === "Initial" || result.waiting,
+ };
+ }),
+ ),
+ [environmentIds],
+ );
+ const resources = useAtomValue(resourcesAtom);
+ const pending = resources.some((resource) => resource.pending);
+ const environmentId = chooseLoadBalancedEnvironment(
+ resources.map((resource) => ({
+ ...resource,
+ weight: weights[resource.environmentId] ?? 50,
+ })),
+ Date.now(),
+ ) as EnvironmentId | null;
+ return { refresh, pending, environmentId };
+}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index f7c47ace68..c0f8ef4d76 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 6ae03719c0..d636c0a953 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 0000000000..fa79f46fbb
--- /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/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts
index 1a95acbcfb..4d68a3231b 100644
--- a/apps/web/src/uiStateStore.test.ts
+++ b/apps/web/src/uiStateStore.test.ts
@@ -26,6 +26,7 @@ function makeUiState(overrides: Partial = {}): UiState {
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
+ pullRequestMergeMethod: "merge",
...overrides,
};
}
@@ -158,6 +159,18 @@ describe("uiStateStore pure functions", () => {
});
describe("parsePersistedState", () => {
+ it("hydrates the last selected pull request merge method", () => {
+ const parsed = parsePersistedState({
+ pullRequestMergeMethod: "squash",
+ });
+ const invalid = parsePersistedState({
+ pullRequestMergeMethod: "fast-forward",
+ });
+
+ expect(parsed.pullRequestMergeMethod).toBe("squash");
+ expect(invalid.pullRequestMergeMethod).toBe("merge");
+ });
+
it("hydrates raw UI-owned state without server entities", () => {
const parsed = parsePersistedState({
projectExpandedById: {
@@ -189,6 +202,7 @@ describe("parsePersistedState", () => {
},
defaultAdvertisedEndpointKey: "desktop-core:lan:http",
sidebarProjectScopeKey: null,
+ pullRequestMergeMethod: "merge",
threadChangedFilesExpandedById: {
"environment:thread-1": {
"turn-1": false,
@@ -317,6 +331,7 @@ describe("uiStateStore persistence", () => {
"turn-2": true,
},
},
+ pullRequestMergeMethod: "merge",
});
expect(parsePersistedState(persisted)).toEqual({
...state,
diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts
index b14ce917c8..e82c86f264 100644
--- a/apps/web/src/uiStateStore.ts
+++ b/apps/web/src/uiStateStore.ts
@@ -1,4 +1,5 @@
import { Debouncer } from "@tanstack/react-pacer";
+import type { PullRequestMergeMethod } from "@t3tools/contracts";
import { create } from "zustand";
import { normalizeProjectPathForComparison } from "./lib/projectPaths";
@@ -29,6 +30,7 @@ export interface PersistedUiState {
sidebarProjectScopeKey?: string | null;
threadChangedFilesExpansionVersion?: number;
threadChangedFilesExpandedById?: Record>;
+ pullRequestMergeMethod?: string;
}
export interface UiProjectState {
@@ -49,7 +51,12 @@ export interface UiEndpointState {
defaultAdvertisedEndpointKey: string | null;
}
-export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {}
+export interface UiPullRequestState {
+ pullRequestMergeMethod: PullRequestMergeMethod;
+}
+
+export interface UiState
+ extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {}
const initialState: UiState = {
projectExpandedById: {},
@@ -58,6 +65,7 @@ const initialState: UiState = {
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
+ pullRequestMergeMethod: "merge",
};
const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:";
@@ -109,6 +117,10 @@ function sanitizeTimestampRecord(value: unknown): Record {
);
}
+function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod {
+ return value === "merge" || value === "squash" || value === "rebase";
+}
+
export function parsePersistedState(parsed: PersistedUiState): UiState {
const projectExpandedById =
parsed.projectExpandedById === undefined
@@ -143,6 +155,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {
: {},
defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey),
sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey),
+ pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod)
+ ? parsed.pullRequestMergeMethod
+ : initialState.pullRequestMergeMethod,
};
}
@@ -216,6 +231,7 @@ export function persistState(state: UiState): void {
sidebarProjectScopeKey: state.sidebarProjectScopeKey,
threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,
+ pullRequestMergeMethod: state.pullRequestMergeMethod,
} satisfies PersistedUiState),
);
if (!legacyKeysCleanedUp) {
@@ -324,6 +340,12 @@ export function setSidebarProjectScopeKey(state: UiState, projectKey: string | n
};
}
+function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState {
+ return state.pullRequestMergeMethod === method
+ ? state
+ : { ...state, pullRequestMergeMethod: method };
+}
+
export function resolveProjectExpanded(
projectExpandedById: Readonly>,
preferenceKeys: readonly string[],
@@ -407,6 +429,7 @@ interface UiStateStore extends UiState {
setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
setDefaultAdvertisedEndpointKey: (key: string | null) => void;
setSidebarProjectScopeKey: (projectKey: string | null) => void;
+ setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void;
setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;
reorderProjects: (
currentProjectOrder: readonly string[],
@@ -427,6 +450,7 @@ export const useUiStateStore = create((set) => ({
set((state) => setDefaultAdvertisedEndpointKey(state, key)),
setSidebarProjectScopeKey: (projectKey) =>
set((state) => setSidebarProjectScopeKey(state, projectKey)),
+ setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)),
setProjectExpanded: (projectIds, expanded) =>
set((state) => setProjectExpanded(state, projectIds, expanded)),
reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>
diff --git a/docs/user/composer.md b/docs/user/composer.md
index fb370e9261..b2b3e73eba 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -81,9 +81,14 @@ uses its account catalog and does not support custom models.
## Model defaults
-Pylon remembers the last provider, model, and model options you selected and reuses that
-selection for new threads. A model configured in a project's settings overrides the remembered
-selection for that project; resetting the project setting returns it to the remembered selection.
+New threads start with the first model that is set, in this order:
+
+1. The project's default model, from its page in **Settings → Projects**.
+2. The default model for the machine the thread runs on, from **All projects** in the same page.
+3. The last provider, model, and model options you selected, which Pylon remembers.
+
+Resetting a project's model returns that project to the machine default. Resetting the machine
+default returns new threads to your remembered selection.
Model options shown as provider defaults remain display values until you choose them in Pylon.
Pylon only sends options you selected explicitly, so an unset reasoning level or service tier can
diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md
index 1edcffdb92..897754a6fc 100644
--- a/docs/user/project-settings.md
+++ b/docs/user/project-settings.md
@@ -1,15 +1,44 @@
-# Customize a project icon
+# Project settings
-On web and desktop, open the sidebar project filter and select the settings button beside a
-project. In **Project icon**, select **Choose icon** for an icon and color or an emoji, or
-**Choose file** for an image in the project. **Reset** returns to automatic selection.
+Open **Settings → Projects**. The project and machine pickers start at **All projects** and
+**All machines**. You can also open a project's settings from the sidebar project filter, the
+chat header, or the command palette.
+
+With **All projects** selected, change the default model, workspace, automatic pull, agent browser
+access, or actions for projects that inherit those values. Select an individual project to override
+a default, and reset its row to inherit again. Changing a default keeps explicit project overrides.
+A workspace preference in `t3.json` takes precedence over machine defaults when the project has no
+workspace override of its own.
+
+Select a machine to limit edits to it. **All machines** writes defaults to connected machines;
+offline machines keep their previous values. When selected machines or checkouts disagree, the row
+says so. Browser access changes apply when an agent session next starts.
+
+A machine running an older Pylon server still saves its workspace and browser access defaults, and
+keeps each project's actions and automatic pull on that project. Its default model, automatic pull,
+and action defaults, and per-project browser access, need a server update first. Settings names the
+machines to update.
+
+Project grouping has a default for this client across machines, with individual checkout overrides.
+Shared actions apply to projects that inherit them; editing a project's actions creates an
+independent list for that checkout. Reset that list to use shared actions again. Existing project
+actions are kept.
+
+Project names, icons, removal, and importing actions from a checkout's `t3.json` stay specific to a
+project. When a project has several checkouts, the checkout picker chooses which actions and grouping
+to edit.
+
+## Project icons
+
+Select a project, then in **Project icon** select **Choose icon** for an icon and color or an
+emoji, or **Choose file** for an image in the project. **Reset** returns to automatic selection.
Pylon checks `t3.json`, common favicon and app icon paths, and icon links in project HTML files.
When no image is available, web and desktop choose an icon from the saved project name. The
same name determines its icon in the sidebar, chat header, command palette, and pull request
filters, even when those places display a different project label.
-Icon and image choices apply to every checkout in a project group. All environments in the
+Icon and image choices apply to the selected checkouts in a project group. All environments in the
group must support saved icons before **Choose icon** is available. An older environment can
still use its existing image picker.
@@ -23,7 +52,11 @@ this cache and can clear it.
## Keep the default branch current
-Turn on **Automatically pull** in a project's settings to keep its default-branch checkout current.
+Turn on **Automatically pull** to keep a default-branch checkout current. Set it under **All
+projects** to make it the default, or select a project to override the default for that project.
+Projects that had automatic pull turned on before project defaults existed keep it on until you
+change or reset them.
+
Pylon checks in the background and when the server starts. It uses the branch's configured
upstream and only performs a fast-forward pull when the checkout has no working-tree changes,
untracked files, or local commits.
diff --git a/docs/user/providers-prime-agent.md b/docs/user/providers-prime-agent.md
index 9fc4a9b137..fc9ed921e5 100644
--- a/docs/user/providers-prime-agent.md
+++ b/docs/user/providers-prime-agent.md
@@ -361,8 +361,9 @@ instead of silently opening a blank or merely recent Prime session.
## Browser access
-When **Settings → Integrations → Browser → Allow agent browser access** is enabled, new Prime Agent
-sessions receive Pylon's thread-scoped preview tools. This works in daemon-backed sessions and ACP
+When **Agent browser access** is enabled in **Settings → Projects**, new Prime Agent sessions receive
+Pylon's thread-scoped preview tools. Set it under **All projects** for a machine default, or select a
+project to override that default. This works in daemon-backed sessions and ACP
compatibility mode. The scoped connection is removed when the provider session stops. Turning browser
access off withholds both the tools and their instructions; it does not affect browser tabs you control.
diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md
index 9d3e7be03b..0f1d4737a4 100644
--- a/docs/user/remote-access.md
+++ b/docs/user/remote-access.md
@@ -67,6 +67,24 @@ shell actions when the environment is remote or its location is still being reso
a remote browser from opening a file manager on an unattended server. Integrated preview, browser,
and copy-path actions remain available when supported.
+## Balance New Threads Across Machines
+
+When a project is grouped across several connected environments, Pylon can choose a machine for
+each new thread. Auto balance is off by default. On web and desktop, turn it on in
+**Settings** → **Connections** → **Load balancing**.
+
+Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and memory
+available, **Less often** to reduce its share, or **Manual only** to exclude it from automatic
+selection. These are preferences, not fixed traffic percentages, and each client saves its own.
+
+A machine is only chosen when it is connected, has the project, and can run the selected provider:
+the provider must be installed, enabled, signed in, and available there. The composer checks those
+machines when it picks a draft's environment, then keeps that choice. Choose **Auto balance** again
+to check current resources, or choose a specific machine to override it. Choosing a branch or
+worktree also keeps the draft on that machine. Existing threads stay where they started. If
+resource checks fail or every eligible machine is busy, choose a machine yourself to continue.
+The mobile app keeps its manual environment selection.
+
## Enabling Network Access
There are three ways to reach your server from another device: expose the desktop app's backend,
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index 09f09d8a7b..c01cdd33dd 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -3,6 +3,10 @@
"private": true,
"type": "module",
"exports": {
+ "./load-balancing": {
+ "types": "./src/load-balancing.ts",
+ "default": "./src/load-balancing.ts"
+ },
"./pending-requests": {
"types": "./src/pendingRequests.ts",
"default": "./src/pendingRequests.ts"
diff --git a/packages/client-runtime/src/load-balancing.test.ts b/packages/client-runtime/src/load-balancing.test.ts
new file mode 100644
index 0000000000..20c9ba7cee
--- /dev/null
+++ b/packages/client-runtime/src/load-balancing.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveLoadBalancingStatus } from "./load-balancing.ts";
+
+describe("resolveLoadBalancingStatus", () => {
+ it("keeps a stored machine balanced regardless of later checks", () => {
+ expect(
+ resolveLoadBalancingStatus({
+ balancedEnvironmentId: "office",
+ pending: true,
+ chosenEnvironmentId: null,
+ }),
+ ).toBe("balanced");
+ });
+
+ it("reports checking while resource requests are still running", () => {
+ expect(
+ resolveLoadBalancingStatus({
+ balancedEnvironmentId: null,
+ pending: true,
+ chosenEnvironmentId: null,
+ }),
+ ).toBe("checking");
+ });
+
+ it("stays balanced while a chosen machine is being saved to the draft", () => {
+ expect(
+ resolveLoadBalancingStatus({
+ balancedEnvironmentId: undefined,
+ pending: false,
+ chosenEnvironmentId: "office",
+ }),
+ ).toBe("balanced");
+ });
+
+ it("is unavailable when finished checks chose no machine, including with no candidates", () => {
+ expect(
+ resolveLoadBalancingStatus({
+ balancedEnvironmentId: null,
+ pending: false,
+ chosenEnvironmentId: null,
+ }),
+ ).toBe("unavailable");
+ });
+});
diff --git a/packages/client-runtime/src/load-balancing.ts b/packages/client-runtime/src/load-balancing.ts
new file mode 100644
index 0000000000..ef46c82807
--- /dev/null
+++ b/packages/client-runtime/src/load-balancing.ts
@@ -0,0 +1,58 @@
+import type { HostResourcesSnapshot } from "@t3tools/contracts";
+
+export type LoadBalancingStatus = "balanced" | "checking" | "unavailable";
+
+/**
+ * What Auto balance can promise an unresolved draft. A finished check that
+ * chose no machine is unavailable even without a failed request: no eligible
+ * candidates, or every candidate stale, saturated or excluded, also blocks send.
+ */
+export function resolveLoadBalancingStatus(input: {
+ readonly balancedEnvironmentId: string | null | undefined;
+ readonly pending: boolean;
+ readonly chosenEnvironmentId: string | null;
+}): LoadBalancingStatus {
+ if (input.balancedEnvironmentId) return "balanced";
+ if (input.pending) return "checking";
+ // A chosen machine the draft has not stored yet is still being saved.
+ return input.chosenEnvironmentId === null ? "unavailable" : "balanced";
+}
+
+/** Callers supply only connected machines hosting the project and selected provider. */
+export function chooseLoadBalancedEnvironment(
+ candidates: ReadonlyArray<{
+ environmentId: string;
+ resources: HostResourcesSnapshot | null;
+ /** Client receipt time avoids comparing clocks on different machines. */
+ receivedAt?: number;
+ weight: number;
+ }>,
+ now: number,
+): string | null {
+ let selected: string | null = null;
+ let bestScore = 0;
+ for (const { environmentId, resources, receivedAt, weight } of candidates) {
+ const sampledAt = receivedAt ?? resources?.sampledAt ?? 0;
+ if (
+ !resources ||
+ !Number.isFinite(weight) ||
+ weight <= 0 ||
+ now - sampledAt > 15_000 ||
+ sampledAt > now + 5_000 ||
+ resources.cpuUtilization === null ||
+ resources.cpuUtilization >= 0.95 ||
+ resources.totalMemoryBytes <= 0 ||
+ resources.cpuCount <= 0
+ ) {
+ continue;
+ }
+ const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes;
+ if (memoryAvailable <= 0.05) continue;
+ const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable;
+ if (score > bestScore) {
+ selected = environmentId;
+ bestScore = score;
+ }
+ }
+ return selected;
+}
diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts
index 94d213b257..4884c3b99b 100644
--- a/packages/client-runtime/src/state/projectGrouping.test.ts
+++ b/packages/client-runtime/src/state/projectGrouping.test.ts
@@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import type { EnvironmentProject } from "./models.ts";
+import { chooseLoadBalancedEnvironment } from "../load-balancing.ts";
import {
buildProjectGroups,
derivePhysicalProjectKey,
@@ -9,6 +10,70 @@ import {
} from "./projectGrouping.ts";
const environmentId = EnvironmentId.make("environment");
+
+describe("load balancing shared project machines", () => {
+ const now = 100_000;
+ const resources = {
+ sampledAt: now,
+ cpuUtilization: 0.2,
+ cpuCount: 8,
+ availableMemoryBytes: 8_000,
+ totalMemoryBytes: 16_000,
+ };
+
+ it("compares three machines using free capacity and preference", () => {
+ const candidates = [
+ { environmentId: "busy", resources: { ...resources, cpuUtilization: 0.9 }, weight: 1 },
+ { environmentId: "idle", resources, weight: 1 },
+ { environmentId: "preferred", resources: { ...resources, cpuCount: 4 }, weight: 3 },
+ ];
+ expect(chooseLoadBalancedEnvironment(candidates, now)).toBe("preferred");
+ expect(chooseLoadBalancedEnvironment(candidates.slice(0, 2), now)).toBe("idle");
+ });
+
+ it("rejects stale, unknown, excluded and saturated machines", () => {
+ expect(
+ chooseLoadBalancedEnvironment(
+ [
+ {
+ environmentId: "stale",
+ resources: { ...resources, sampledAt: now - 15_001 },
+ weight: 1,
+ },
+ { environmentId: "unknown", resources: null, weight: 1 },
+ {
+ environmentId: "no-cpu-sample",
+ resources: { ...resources, cpuUtilization: null },
+ weight: 1,
+ },
+ { environmentId: "excluded", resources, weight: 0 },
+ {
+ environmentId: "cpu-full",
+ resources: { ...resources, cpuUtilization: 0.95 },
+ weight: 1,
+ },
+ {
+ environmentId: "memory-full",
+ resources: { ...resources, availableMemoryBytes: 100 },
+ weight: 1,
+ },
+ ],
+ now,
+ ),
+ ).toBeNull();
+ });
+
+ it("uses client receipt time when host clocks differ", () => {
+ const candidate = {
+ environmentId: "different-clock",
+ resources: { ...resources, sampledAt: now + 60_000 },
+ receivedAt: now,
+ weight: 1,
+ };
+ expect(chooseLoadBalancedEnvironment([candidate], now)).toBe("different-clock");
+ expect(chooseLoadBalancedEnvironment([candidate], now + 15_001)).toBeNull();
+ });
+});
const repositoryIdentity = {
canonicalKey: "github.com/t3tools/t3code",
locator: {
diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts
index b2d8d3f197..5ce1e0b32d 100644
--- a/packages/client-runtime/src/state/server.ts
+++ b/packages/client-runtime/src/state/server.ts
@@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
import {
createAtomCommandScheduler,
createEnvironmentRpcCommand,
+ createEnvironmentQueryAtomFamily,
createEnvironmentRpcQueryAtomFamily,
createEnvironmentRpcSubscriptionAtomFamily,
createRuntimeCommand,
@@ -995,6 +996,13 @@ export function createServerEnvironmentAtoms(
label: "environment-data:server:process-diagnostics",
tag: WS_METHODS.serverGetProcessDiagnostics,
}),
+ hostResources: createEnvironmentQueryAtomFamily(runtime, {
+ label: "environment-data:server:host-resources",
+ idleTtlMs: 0,
+ staleTimeMs: 5_000,
+ execute: (input: EnvironmentRpcInput) =>
+ request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")),
+ }),
processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:server:process-resource-history",
tag: WS_METHODS.serverGetProcessResourceHistory,
diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts
index d713e8a63c..73c4aa563c 100644
--- a/packages/client-runtime/src/state/sharedSettings.test.ts
+++ b/packages/client-runtime/src/state/sharedSettings.test.ts
@@ -90,13 +90,19 @@ describe("splitSharedServerPatch", () => {
sidebarAutoSettleOnMerge: false,
continueThreadsAfterServerUpdate: true,
enableAgentBrowserAccess: false,
+ defaultThreadEnvMode: "worktree",
+ newWorktreesStartFromOrigin: true,
});
expect(sharedPatch).toEqual({
sidebarAutoSettleAfterDays: 7,
sidebarAutoSettleOnMerge: false,
continueThreadsAfterServerUpdate: true,
+ newWorktreesStartFromOrigin: true,
+ });
+ expect(localPatch).toEqual({
+ enableAgentBrowserAccess: false,
+ defaultThreadEnvMode: "worktree",
});
- expect(localPatch).toEqual({ enableAgentBrowserAccess: false });
});
});
@@ -106,7 +112,6 @@ describe("pickSharedServerSettings", () => {
Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(),
).toEqual([
"continueThreadsAfterServerUpdate",
- "defaultThreadEnvMode",
"newWorktreesStartFromOrigin",
"sidebarAutoSettleAfterDays",
"sidebarAutoSettleOnMerge",
@@ -328,7 +333,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 6ce4323cd1..1302d96606 100644
--- a/packages/client-runtime/src/state/sharedSettings.ts
+++ b/packages/client-runtime/src/state/sharedSettings.ts
@@ -24,7 +24,6 @@ const SHARED_SERVER_SETTING_KEYS = [
"continueThreadsAfterServerUpdate",
"sidebarAutoSettleAfterDays",
"sidebarAutoSettleOnMerge",
- "defaultThreadEnvMode",
"newWorktreesStartFromOrigin",
"sourceControlWritingStyle",
"textGenerationModelSelection",
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 7ff9b26ba2..c70ee6e332 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -146,6 +146,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
/** Server retains the browser profile selected at preview.open. Older servers
discard that field, so nondefault profiles must not be opened through them. */
browserProfiles: Schema.optionalKey(Schema.Boolean),
+ /** Server persists machine project defaults and per-project overrides
+ (default model, actions and automatic pull, and the action, automatic
+ pull and browser access override maps). Older servers strip those
+ settings patch keys without an error and clients decode them as
+ defaults, so writes must check this rather than the settings they read. */
+ projectDefaults: Schema.optionalKey(Schema.Boolean),
/** The desktop app supervising this server can be driven over RPC:
server.updateServer runs its check -> download -> relaunch. Absent on
desktop servers whose app predates the remote trigger, where clients
diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts
index fc519c3bc6..ee9d2b3ac2 100644
--- a/packages/contracts/src/resourceTelemetry.ts
+++ b/packages/contracts/src/resourceTelemetry.ts
@@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts";
export const RESOURCE_MONITOR_PROTOCOL_VERSION = 3 as const;
+/** Whole-host capacity, independent of T3's process diagnostics. */
+export const HostResourcesSnapshot = Schema.Struct({
+ sampledAt: NonNegativeInt,
+ cpuUtilization: Schema.NullOr(Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))),
+ cpuCount: NonNegativeInt,
+ availableMemoryBytes: NonNegativeInt,
+ totalMemoryBytes: NonNegativeInt,
+});
+export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type;
+
export const ResourceTelemetryIoSemantics = Schema.Literals([
"storage",
"logical",
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 52e050ea5d..4dd1f6c8ee 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -261,6 +261,7 @@ import {
ServerUpsertKeybindingResult,
} from "./server.ts";
import {
+ HostResourcesSnapshot,
ResourceTelemetryHistory,
ResourceTelemetryHistoryInput,
ResourceTelemetryRetryResult,
@@ -405,6 +406,7 @@ export const WS_METHODS = {
serverDiscoverSourceControl: "server.discoverSourceControl",
serverGetTraceDiagnostics: "server.getTraceDiagnostics",
serverGetProcessDiagnostics: "server.getProcessDiagnostics",
+ serverGetHostResources: "server.getHostResources",
serverGetProcessResourceHistory: "server.getProcessResourceHistory",
serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",
serverRetryResourceTelemetry: "server.retryResourceTelemetry",
@@ -815,6 +817,12 @@ const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDia
error: EnvironmentAuthorizationError,
});
+const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, {
+ payload: Schema.Struct({}),
+ success: HostResourcesSnapshot,
+ error: EnvironmentAuthorizationError,
+});
+
const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, {
payload: ServerProcessResourceHistoryInput,
success: ServerProcessResourceHistoryResult,
@@ -1479,6 +1487,7 @@ export const WsRpcGroup = RpcGroup.make(
WsServerDiscoverSourceControlRpc,
WsServerGetTraceDiagnosticsRpc,
WsServerGetProcessDiagnosticsRpc,
+ WsServerGetHostResourcesRpc,
WsServerGetProcessResourceHistoryRpc,
WsServerGetResourceTelemetryHistoryRpc,
WsServerRetryResourceTelemetryRpc,
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 328c8188ce..670df4397a 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -14,6 +14,7 @@ import {
const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);
+const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema);
const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
const encodeServerSettings = Schema.encodeSync(ServerSettings);
@@ -162,6 +163,21 @@ describe("ClientSettings retired status motion", () => {
});
});
+describe("ClientSettings load balancing", () => {
+ it("requires opt-in when settings are new or omit load balancing", () => {
+ expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false);
+ expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false);
+ });
+
+ it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => {
+ const settings = decodeClientSettings({ loadBalancingEnabled });
+ expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled);
+ expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe(
+ loadBalancingEnabled,
+ );
+ });
+});
+
describe("ClientSettings word wrap", () => {
it("defaults word wrap on", () => {
expect(decodeClientSettings({}).wordWrap).toBe(true);
@@ -412,6 +428,25 @@ describe("ServerSettings Prime Agent provider", () => {
});
});
+describe("ClientSettings pull request merge methods", () => {
+ it("defaults to no project overrides and accepts supported methods", () => {
+ expect(decodeClientSettings({}).pullRequestMergeMethodOverrides).toEqual({});
+ expect(
+ decodeClientSettingsPatch({
+ pullRequestMergeMethodOverrides: { project: "squash" },
+ }).pullRequestMergeMethodOverrides,
+ ).toEqual({ project: "squash" });
+ });
+
+ it("rejects unsupported project merge methods", () => {
+ expect(() =>
+ decodeClientSettingsPatch({
+ pullRequestMergeMethodOverrides: { project: "fast-forward" },
+ }),
+ ).toThrow();
+ });
+});
+
describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
it("defaults text generation to Luna at low reasoning effort", () => {
expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 85546b9cce..58005e809a 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -2,7 +2,12 @@ import * as Effect from "effect/Effect";
import * as Duration from "effect/Duration";
import * as Schema from "effect/Schema";
import * as SchemaTransformation from "effect/SchemaTransformation";
-import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
+import {
+ ForwardCompatibleNullable,
+ ProjectId,
+ TrimmedNonEmptyString,
+ TrimmedString,
+} from "./baseSchemas.ts";
import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
import {
@@ -11,7 +16,7 @@ import {
DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
ProviderOptionSelections,
} from "./model.ts";
-import { ModelSelection } from "./orchestration.ts";
+import { ModelSelection, ProjectScript } from "./orchestration.ts";
import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
import {
DEFAULT_PREVIEW_APPEARANCE,
@@ -26,6 +31,7 @@ import {
ProviderInstanceId,
type ProviderDriverKind,
} from "./providerInstance.ts";
+import { PullRequestMergeMethod } from "./pullRequest.ts";
// ── Client Settings (local-only) ───────────────────────────────
@@ -184,7 +190,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]);
export type BrowserLinkTarget = typeof BrowserLinkTarget.Type;
export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system";
+export const LoadBalancingWeights = Schema.Record(
+ TrimmedNonEmptyString,
+ Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
+);
+
export const ClientSettingsSchema = Schema.Struct({
+ loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
appearanceContrast: AppearanceContrast.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),
),
@@ -289,6 +302,10 @@ export const ClientSettingsSchema = Schema.Struct({
modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
}),
).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ pullRequestMergeMethodOverrides: Schema.Record(
+ TrimmedNonEmptyString,
+ PullRequestMergeMethod,
+ ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
// Legacy sidebar (the original per-project tree). Deliberately a fresh key
@@ -890,6 +907,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)),
),
@@ -1170,6 +1203,18 @@ export const ServerSettingsPatch = Schema.Struct({
enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
+ projectAgentBrowserAccessOverrides: Schema.optionalKey(
+ Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
+ ),
+ defaultAutoPull: Schema.optionalKey(Schema.Boolean),
+ defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)),
+ projectScriptOverrides: Schema.optionalKey(
+ Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))),
+ ),
+ projectAutoPullOverrides: Schema.optionalKey(
+ Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
+ ),
+ defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
backgroundActivity: Schema.optionalKey(
@@ -1279,6 +1324,8 @@ export class ServerSettingsUpdateConflictError extends Schema.TaggedErrorClass,
+ 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 31f056c211..a5e428fcda 100644
--- a/packages/shared/src/serverSettings.test.ts
+++ b/packages/shared/src/serverSettings.test.ts
@@ -1,5 +1,6 @@
import {
DEFAULT_SERVER_SETTINGS,
+ ProjectId,
ProviderDriverKind,
ProviderInstanceId,
UsageLimitSourceId,
@@ -9,14 +10,181 @@ import * as Duration from "effect/Duration";
import { describe, expect, it } from "vite-plus/test";
import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts";
import { createModelSelection } from "./model.ts";
+import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";
import {
applyServerSettingsPatch,
isModelSelectionProviderEnabled,
parsePersistedServerObservabilitySettings,
resolveSourceControlWriterModelSelection,
+ resolveProjectAgentBrowserAccess,
+ resolveProjectAutoPull,
} from "./serverSettings.ts";
describe("serverSettings helpers", () => {
+ it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => {
+ const project = { id: ProjectId.make("project-actions"), scripts: [] };
+ const action = {
+ id: "check",
+ name: "Check",
+ command: "npm test",
+ icon: "play" as const,
+ runOnWorktreeCreate: false,
+ };
+ const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ defaultProjectScripts: [action],
+ });
+ expect(resolveProjectScripts(defaults, project)).toEqual([action]);
+ expect(projectScriptsInheritDefaults(defaults, project)).toBe(true);
+ const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] };
+ expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts);
+ expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false);
+ const disabled = applyServerSettingsPatch(defaults, {
+ projectScriptOverrides: { [project.id]: [] },
+ });
+ expect(resolveProjectScripts(disabled, project)).toEqual([]);
+ expect(projectScriptsInheritDefaults(disabled, project)).toBe(false);
+ const changedDefault = applyServerSettingsPatch(disabled, {
+ defaultProjectScripts: [{ ...action, command: "npm run build" }],
+ });
+ expect(resolveProjectScripts(changedDefault, project)).toEqual([]);
+ const reset = applyServerSettingsPatch(changedDefault, {
+ projectScriptOverrides: { [project.id]: null },
+ });
+ expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts);
+ expect(projectScriptsInheritDefaults(reset, existing)).toBe(true);
+ expect(
+ resolveProjectScripts(
+ applyServerSettingsPatch(reset, { defaultProjectScripts: [] }),
+ existing,
+ ),
+ ).toEqual([]);
+ });
+
+ it("preserves other projects' actions when overriding, clearing, or resetting one project", () => {
+ const firstProject = { id: ProjectId.make("first-project"), scripts: [] };
+ const secondProject = { id: ProjectId.make("second-project"), scripts: [] };
+ const defaultAction = {
+ id: "check",
+ name: "Check",
+ command: "npm test",
+ icon: "play" as const,
+ runOnWorktreeCreate: false,
+ };
+ const firstAction = { ...defaultAction, command: "npm run lint" };
+ const secondAction = { ...defaultAction, command: "npm run build" };
+ const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ defaultProjectScripts: [defaultAction],
+ projectScriptOverrides: { [firstProject.id]: [firstAction] },
+ });
+ const secondUpdate = applyServerSettingsPatch(firstUpdate, {
+ projectScriptOverrides: { [secondProject.id]: [secondAction] },
+ });
+ expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]);
+ expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]);
+
+ const cleared = applyServerSettingsPatch(secondUpdate, {
+ projectScriptOverrides: { [firstProject.id]: [] },
+ });
+ expect(resolveProjectScripts(cleared, firstProject)).toEqual([]);
+ expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]);
+
+ const reset = applyServerSettingsPatch(cleared, {
+ projectScriptOverrides: { [firstProject.id]: null },
+ });
+ expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([
+ defaultAction,
+ ]);
+ expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]);
+ expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]);
+ });
+
+ it("inherits automatic pull while preserving legacy opt-ins and explicit overrides", () => {
+ const projectId = ProjectId.make("project-pull");
+ expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, false)).toBe(false);
+ expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, true)).toBe(true);
+ const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultAutoPull: true });
+ expect(resolveProjectAutoPull(enabled, projectId, false)).toBe(true);
+ const overridden = applyServerSettingsPatch(enabled, {
+ projectAutoPullOverrides: { [projectId]: false },
+ });
+ expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false);
+ const reset = applyServerSettingsPatch(overridden, {
+ projectAutoPullOverrides: { [projectId]: null },
+ });
+ expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true);
+ const disabled = applyServerSettingsPatch(reset, {
+ defaultAutoPull: false,
+ projectAutoPullOverrides: { [projectId]: true },
+ });
+ expect(resolveProjectAutoPull(disabled, projectId, false)).toBe(true);
+ expect(resolveProjectAutoPull(disabled, ProjectId.make("other-project"), false)).toBe(false);
+ });
+
+ it("inherits browser access and restores inheritance when a project override is removed", () => {
+ const projectId = ProjectId.make("project-browser");
+ const otherProjectId = ProjectId.make("other-project");
+ const overridden = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ projectAgentBrowserAccessOverrides: { [projectId]: false },
+ });
+ expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false);
+ expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true);
+ const reset = applyServerSettingsPatch(overridden, {
+ projectAgentBrowserAccessOverrides: { [projectId]: null },
+ });
+ expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true);
+ const enabled = applyServerSettingsPatch(reset, {
+ enableAgentBrowserAccess: false,
+ projectAgentBrowserAccessOverrides: { [projectId]: true },
+ });
+ expect(resolveProjectAgentBrowserAccess(enabled, projectId)).toBe(true);
+ expect(resolveProjectAgentBrowserAccess(enabled, otherProjectId)).toBe(false);
+ });
+
+ it("preserves other projects' boolean overrides across separate updates and resets", () => {
+ const firstProjectId = ProjectId.make("first-project");
+ const secondProjectId = ProjectId.make("second-project");
+ const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ defaultAutoPull: true,
+ projectAutoPullOverrides: { [firstProjectId]: false },
+ projectAgentBrowserAccessOverrides: { [firstProjectId]: false },
+ });
+ const secondUpdate = applyServerSettingsPatch(firstUpdate, {
+ projectAutoPullOverrides: { [secondProjectId]: false },
+ projectAgentBrowserAccessOverrides: { [secondProjectId]: false },
+ });
+ for (const projectId of [firstProjectId, secondProjectId]) {
+ expect(resolveProjectAutoPull(secondUpdate, projectId, false)).toBe(false);
+ expect(resolveProjectAgentBrowserAccess(secondUpdate, projectId)).toBe(false);
+ }
+
+ const reset = applyServerSettingsPatch(secondUpdate, {
+ projectAutoPullOverrides: { [firstProjectId]: null },
+ projectAgentBrowserAccessOverrides: { [firstProjectId]: null },
+ });
+ expect(resolveProjectAutoPull(reset, firstProjectId, false)).toBe(true);
+ expect(resolveProjectAgentBrowserAccess(reset, firstProjectId)).toBe(true);
+ expect(resolveProjectAutoPull(reset, secondProjectId, false)).toBe(false);
+ expect(resolveProjectAgentBrowserAccess(reset, secondProjectId)).toBe(false);
+ expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined();
+ expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined();
+ expect(resolveProjectAutoPull(secondUpdate, firstProjectId, false)).toBe(false);
+ expect(resolveProjectAgentBrowserAccess(secondUpdate, firstProjectId)).toBe(false);
+ });
+
+ it("replaces and clears conversation model defaults without retaining old options", () => {
+ const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
+ { id: "reasoningEffort", value: "high" },
+ ]),
+ });
+ const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "sonnet");
+ const updated = applyServerSettingsPatch(current, { defaultModelSelection: selection });
+ expect(updated.defaultModelSelection).toEqual(selection);
+ expect(
+ applyServerSettingsPatch(updated, { defaultModelSelection: null }).defaultModelSelection,
+ ).toBeNull();
+ });
+
it("ignores missing and blank persisted observability URLs", () => {
expect(parsePersistedServerObservabilitySettings("{}")).toEqual({
otlpTracesUrl: undefined,
diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts
index dc50da2d76..f969e4412c 100644
--- a/packages/shared/src/serverSettings.ts
+++ b/packages/shared/src/serverSettings.ts
@@ -3,6 +3,7 @@ import {
isProviderAvailable,
resolveProviderInstanceEnabled,
type ModelSelection,
+ type ProjectId,
type ProviderDriverKind,
type ServerProvider,
ServerSettings,
@@ -23,6 +24,27 @@ import {
const ServerSettingsJson = fromLenientJson(ServerSettings);
const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
+export function resolveProjectAgentBrowserAccess(
+ settings: Pick,
+ projectId: ProjectId,
+): boolean {
+ return (
+ settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess
+ );
+}
+
+export function resolveProjectAutoPull(
+ settings: Pick,
+ projectId: ProjectId,
+ legacyAutoPull: boolean | undefined,
+): boolean {
+ // Existing opt-ins stay enabled until explicitly overridden or reset.
+ return (
+ settings.projectAutoPullOverrides[projectId] ??
+ (legacyAutoPull === true || settings.defaultAutoPull)
+ );
+}
+
type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
const getLegacyProviderSettings = (
@@ -151,6 +173,8 @@ export function applyServerSettingsPatch(
// Merged per entry below; its `null` removals must not reach deepMerge.
usageLimitSources: usageLimitSourcesPatch,
usagePriceOverrides: usagePriceOverridesPatch,
+ projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch,
+ projectAutoPullOverrides: projectAutoPullOverridesPatch,
...patchForMerge
} = patch;
const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
@@ -207,6 +231,36 @@ export function applyServerSettingsPatch(
...(patch.providerInstances !== undefined
? { providerInstances: patch.providerInstances }
: {}),
+ ...(projectAgentBrowserAccessOverridesPatch !== undefined
+ ? {
+ projectAgentBrowserAccessOverrides: mergeSettingsEntries(
+ current.projectAgentBrowserAccessOverrides,
+ projectAgentBrowserAccessOverridesPatch,
+ ),
+ }
+ : {}),
+ ...(projectAutoPullOverridesPatch !== undefined
+ ? {
+ projectAutoPullOverrides: mergeSettingsEntries(
+ current.projectAutoPullOverrides,
+ projectAutoPullOverridesPatch,
+ ),
+ }
+ : {}),
+ ...(patch.defaultModelSelection !== undefined
+ ? { defaultModelSelection: patch.defaultModelSelection }
+ : {}),
+ ...(patch.defaultProjectScripts !== undefined
+ ? { defaultProjectScripts: patch.defaultProjectScripts }
+ : {}),
+ ...(patch.projectScriptOverrides !== undefined
+ ? {
+ projectScriptOverrides: {
+ ...current.projectScriptOverrides,
+ ...patch.projectScriptOverrides,
+ },
+ }
+ : {}),
...(usageLimitSourcesPatch !== undefined
? {
usageLimitSources: mergeSettingsEntries(