authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-08 10:37:37+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 13:07:37+05:30
log7d9aaf6a7eb22d5e85b0e3b21686cc437fe68576
tree643dcd07672424ea5ea5e5bf41bcd9dda6a07082
parent430fbd1ffc98378f6a6a41027ddc6004a79b2e7b
signature Signed by PGP key B5690EEEBB952194

feat(web): add pull request merge defaults (#8088)


9 files changed, 219 insertions(+), 21 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -64,6 +64,7 @@ const clientSettings: ClientSettings = {...@@ -64,6 +64,7 @@ const clientSettings: ClientSettings = {
64 legacySidebarEnabled: false,64 legacySidebarEnabled: false,
65 loadBalancingEnabled: false,65 loadBalancingEnabled: false,
66 loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },66 loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
67 pullRequestMergeMethodOverrides: {},
67 timestampFormat: "24-hour",68 timestampFormat: "24-hour",
68 wordWrap: true,69 wordWrap: true,
69};70};
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+65-20
...@@ -53,14 +53,21 @@ import {...@@ -53,14 +53,21 @@ import {
5353
54import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";54import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
55import { useNewThreadHandler } from "~/hooks/useHandleNewThread";55import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
56import { useClientSettings } from "~/hooks/useSettings";
56import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";57import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
58import {
59 deriveLogicalProjectKeyFromSettings,
60 derivePhysicalProjectKey,
61 selectProjectGroupingSettings,
62} from "~/logicalProject";
57import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";63import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";
58import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";64import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
59import { cn } from "~/lib/utils";65import { cn } from "~/lib/utils";
60import { readLocalApi } from "~/localApi";66import { readLocalApi } from "~/localApi";
61import type { ReviewCommentContext } from "~/reviewCommentContext";67import type { ReviewCommentContext } from "~/reviewCommentContext";
68import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";
62import { useProjects } from "~/state/entities";69import { useProjects } from "~/state/entities";
63import { useEnvironments } from "~/state/environments";70import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
64import { useEnvironmentQuery } from "~/state/query";71import { useEnvironmentQuery } from "~/state/query";
65import { useLiveRefresh } from "~/hooks/useLiveRefresh";72import { useLiveRefresh } from "~/hooks/useLiveRefresh";
66import {73import {
...@@ -71,6 +78,7 @@ import {...@@ -71,6 +78,7 @@ import {
71import { useAtomCommand } from "~/state/use-atom-command";78import { useAtomCommand } from "~/state/use-atom-command";
72import { vcsEnvironment } from "~/state/vcs";79import { vcsEnvironment } from "~/state/vcs";
73import { formatRelativeTimeLabel } from "~/timestampFormat";80import { formatRelativeTimeLabel } from "~/timestampFormat";
81import { useUiStateStore } from "~/uiStateStore";
7482
75import {83import {
76 AlertDialog,84 AlertDialog,
...@@ -124,11 +132,13 @@ import {...@@ -124,11 +132,13 @@ import {
124 pullRequestCheckoutCommand,132 pullRequestCheckoutCommand,
125 pullRequestFindingKey,133 pullRequestFindingKey,
126 pullRequestHandoffLabels,134 pullRequestHandoffLabels,
135 PULL_REQUEST_MERGE_METHOD_LABELS,
127 readableFailure,136 readableFailure,
128 readPullRequestDetailSnapshot,137 readPullRequestDetailSnapshot,
129 resolveDisplayedPullRequestDetail,138 resolveDisplayedPullRequestDetail,
130 resolvePullRequestPrimaryControl,139 resolvePullRequestPrimaryControl,
131 resolveBaseFreshness,140 resolveBaseFreshness,
141 resolvePullRequestMergeMethod,
132 type PullRequestFinding,142 type PullRequestFinding,
133 shouldRefreshPullRequestActivity,143 shouldRefreshPullRequestActivity,
134 writePullRequestDetailSnapshot,144 writePullRequestDetailSnapshot,
...@@ -168,12 +178,6 @@ const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {...@@ -168,12 +178,6 @@ const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {
168 "approve-workflows": "Workflows approved",178 "approve-workflows": "Workflows approved",
169};179};
170180
171const MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = {
172 merge: "Merge",
173 squash: "Squash",
174 rebase: "Rebase",
175};
176
177/** Said as the thing that did not happen, rather than as the operation that returned an error. */181/** Said as the thing that did not happen, rather than as the operation that returned an error. */
178const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {182const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {
179 merge: "Could not merge this pull request",183 merge: "Could not merge this pull request",
...@@ -553,12 +557,16 @@ export function PullRequestDetailPanel({...@@ -553,12 +557,16 @@ export function PullRequestDetailPanel({
553 compensationRef.current = null;557 compensationRef.current = null;
554 if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);558 if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);
555 }, [condensed]);559 }, [condensed]);
560 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
561 const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);
562 const mergeMethodOverrides = useClientSettings(
563 (settings) => settings.pullRequestMergeMethodOverrides,
564 );
565 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
556 const [mergeMethodSelection, setMergeMethodSelection] = useState<{566 const [mergeMethodSelection, setMergeMethodSelection] = useState<{
557 readonly pullRequestKey: string;567 readonly pullRequestKey: string;
558 readonly method: PullRequestMergeMethod;568 readonly method: PullRequestMergeMethod;
559 }>(() => ({ pullRequestKey, method: "merge" }));569 } | null>(null);
560 const mergeMethod =
561 mergeMethodSelection.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : "merge";
562 const setMergeMethod = (method: PullRequestMergeMethod) => {570 const setMergeMethod = (method: PullRequestMergeMethod) => {
563 setMergeMethodSelection({ pullRequestKey, method });571 setMergeMethodSelection({ pullRequestKey, method });
564 };572 };
...@@ -763,6 +771,7 @@ export function PullRequestDetailPanel({...@@ -763,6 +771,7 @@ export function PullRequestDetailPanel({
763 const [titleSaving, setTitleSaving] = useState(false);771 const [titleSaving, setTitleSaving] = useState(false);
764 const newThread = useNewThreadHandler();772 const newThread = useNewThreadHandler();
765 const { environments } = useEnvironments();773 const { environments } = useEnvironments();
774 const primaryEnvironmentId = usePrimaryEnvironmentId();
766 const projects = useProjects();775 const projects = useProjects();
767 const unavailableGitHubUrl = useMemo(() => {776 const unavailableGitHubUrl = useMemo(() => {
768 const identity = projects.find(777 const identity = projects.find(
...@@ -770,6 +779,30 @@ export function PullRequestDetailPanel({...@@ -770,6 +779,30 @@ export function PullRequestDetailPanel({
770 )?.repositoryIdentity;779 )?.repositoryIdentity;
771 return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);780 return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);
772 }, [environmentId, projects, reference.number, reference.projectId, reference.repository]);781 }, [environmentId, projects, reference.number, reference.projectId, reference.repository]);
782 // Project settings store the override under the sidebar group's key, which a duplicate row
783 // borrows from its siblings, so the project alone does not always name the same key.
784 const projectDefaultMergeMethod = useMemo(() => {
785 const project = projects.find(
786 (candidate) =>
787 candidate.environmentId === environmentId && candidate.id === reference.projectId,
788 );
789 if (!project) return undefined;
790 const projectKey =
791 buildPhysicalToLogicalProjectKeyMap({
792 projects,
793 settings: projectGroupingSettings,
794 primaryEnvironmentId,
795 }).get(derivePhysicalProjectKey(project)) ??
796 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings);
797 return mergeMethodOverrides[projectKey];
798 }, [
799 environmentId,
800 mergeMethodOverrides,
801 primaryEnvironmentId,
802 projectGroupingSettings,
803 projects,
804 reference.projectId,
805 ]);
773 // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and806 // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and
774 // the thread is already on one server's copy of the branch.807 // the thread is already on one server's copy of the branch.
775 const pickableEnvironments = useMemo(808 const pickableEnvironments = useMemo(
...@@ -1234,10 +1267,15 @@ export function PullRequestDetailPanel({...@@ -1234,10 +1267,15 @@ export function PullRequestDetailPanel({
1234 const allowedMergeMethods = detail1267 const allowedMergeMethods = detail
1235 ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method])1268 ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method])
1236 : [];1269 : [];
1237 const selectedMergeMethod = allowedMergeMethods.includes(mergeMethod)1270 const currentMergeMethod =
1238 ? mergeMethod1271 mergeMethodSelection?.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : null;
1239 : (allowedMergeMethods[0] ?? "merge");1272 const selectedMergeMethod = resolvePullRequestMergeMethod(
1240 const selectedMergeMethodLabel = MERGE_METHOD_LABELS[selectedMergeMethod];1273 allowedMergeMethods,
1274 currentMergeMethod,
1275 projectDefaultMergeMethod,
1276 lastSelectedMergeMethod,
1277 );
1278 const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];
1241 const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`;1279 const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`;
1242 const conflicting = detail?.state === "open" && detail.mergeability === "conflicting";1280 const conflicting = detail?.state === "open" && detail.mergeability === "conflicting";
1243 // Only an outright yes arms it. A host that reports nothing has not said the merge is already1281 // Only an outright yes arms it. A host that reports nothing has not said the merge is already
...@@ -1245,7 +1283,7 @@ export function PullRequestDetailPanel({...@@ -1245,7 +1283,7 @@ export function PullRequestDetailPanel({
1245 const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true;1283 const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true;
1246 const armedMergeMethod = detail?.autoMergeMethod;1284 const armedMergeMethod = detail?.autoMergeMethod;
1247 const armedAutoMergeLabel = armedMergeMethod1285 const armedAutoMergeLabel = armedMergeMethod
1248 ? `Auto-merge (${MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})`1286 ? `Auto-merge (${PULL_REQUEST_MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})`
1249 : "Auto-merge";1287 : "Auto-merge";
1250 const workflowApprovalsRequired =1288 const workflowApprovalsRequired =
1251 detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0;1289 detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0;
...@@ -1783,17 +1821,24 @@ export function PullRequestDetailPanel({...@@ -1783,17 +1821,24 @@ export function PullRequestDetailPanel({
1783 ) : null}1821 ) : null}
1784 <MenuRadioGroup1822 <MenuRadioGroup
1785 value={selectedMergeMethod}1823 value={selectedMergeMethod}
1786 onValueChange={(method) =>1824 onValueChange={(method) => {
1787 setMergeMethod(method as PullRequestMergeMethod)1825 const selectedMethod = method as PullRequestMergeMethod;
1788 }1826 setMergeMethod(selectedMethod);
1827 setLastSelectedMergeMethod(selectedMethod);
1828 }}
1789 >1829 >
1790 {allowedMergeMethods.map((method) => (1830 {allowedMergeMethods.map((method) => (
1791 <MenuRadioItem key={method} value={method} disabled={actionPending}>1831 <MenuRadioItem
1832 key={method}
1833 value={method}
1834 disabled={actionPending}
1835 closeOnClick
1836 >
1792 {/* The radio item lays its children out as one block, so the1837 {/* The radio item lays its children out as one block, so the
1793 icon and the label need their own row to share a line. */}1838 icon and the label need their own row to share a line. */}
1794 <span className="flex min-w-0 items-center gap-2">1839 <span className="flex min-w-0 items-center gap-2">
1795 <GitMergeIcon className="size-3.5" />1840 <GitMergeIcon className="size-3.5" />
1796 <span>{MERGE_METHOD_LABELS[method]}</span>1841 <span>{PULL_REQUEST_MERGE_METHOD_LABELS[method]}</span>
1797 </span>1842 </span>
1798 </MenuRadioItem>1843 </MenuRadioItem>
1799 ))}1844 ))}
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+16
...@@ -37,6 +37,7 @@ import {...@@ -37,6 +37,7 @@ import {
37 resolvePullRequestPrimaryControl,37 resolvePullRequestPrimaryControl,
38 shouldRefreshPullRequestActivity,38 shouldRefreshPullRequestActivity,
39 resolveBaseFreshness,39 resolveBaseFreshness,
40 resolvePullRequestMergeMethod,
40 buildPullRequestTimeline,41 buildPullRequestTimeline,
41 editPullRequestThreadComment,42 editPullRequestThreadComment,
42 writePullRequestDetailSnapshot,43 writePullRequestDetailSnapshot,
...@@ -85,6 +86,21 @@ const TIMELINE_SOURCE: Pick<...@@ -85,6 +86,21 @@ const TIMELINE_SOURCE: Pick<
85 closedAt: null,86 closedAt: null,
86};87};
8788
89describe("pull request merge method", () => {
90 it("uses the current choice, then the project default, then the last choice", () => {
91 expect(
92 resolvePullRequestMergeMethod(["merge", "squash", "rebase"], null, "squash", "rebase"),
93 ).toBe("squash");
94 expect(
95 resolvePullRequestMergeMethod(["merge", "squash", "rebase"], "rebase", "squash", "merge"),
96 ).toBe("rebase");
97 expect(resolvePullRequestMergeMethod(["merge", "rebase"], null, "squash", "rebase")).toBe(
98 "rebase",
99 );
100 expect(resolvePullRequestMergeMethod(["squash"], null, "merge", "rebase")).toBe("squash");
101 });
102});
103
88describe("pull request activity refresh", () => {104describe("pull request activity refresh", () => {
89 const first = {105 const first = {
90 key: "project:acme/web#7",106 key: "project:acme/web#7",
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+19
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 type PullRequestCommit,11 type PullRequestCommit,
12 type PullRequestDetailView,12 type PullRequestDetailView,
13 type PullRequestMergeability,13 type PullRequestMergeability,
14 type PullRequestMergeMethod,
14 type PullRequestReaction,15 type PullRequestReaction,
15 type PullRequestReviewThread,16 type PullRequestReviewThread,
16 type PullRequestState,17 type PullRequestState,
...@@ -21,6 +22,24 @@ import {...@@ -21,6 +22,24 @@ import {
2122
22import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";23import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";
2324
25export const PULL_REQUEST_MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = {
26 merge: "Merge",
27 squash: "Squash and merge",
28 rebase: "Rebase and merge",
29};
30
31export function resolvePullRequestMergeMethod(
32 allowed: ReadonlyArray<PullRequestMergeMethod>,
33 current: PullRequestMergeMethod | null,
34 projectDefault: PullRequestMergeMethod | undefined,
35 lastSelected: PullRequestMergeMethod,
36): PullRequestMergeMethod {
37 for (const method of [current, projectDefault, lastSelected]) {
38 if (method && allowed.includes(method)) return method;
39 }
40 return allowed[0] ?? "merge";
41}
42
24const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;43const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
25const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;44const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
2645
apps/web/src/components/settings/ProjectSettingsPanel.tsx+51
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 type ResolvedKeybindingsConfig,21 type ResolvedKeybindingsConfig,
22 type ServerSettings,22 type ServerSettings,
23 type ProviderDriverKind,23 type ProviderDriverKind,
24 type PullRequestMergeMethod,
24 type SidebarProjectGroupingMode,25 type SidebarProjectGroupingMode,
25 type T3ProjectFileScript,26 type T3ProjectFileScript,
26 type ThreadEnvMode,27 type ThreadEnvMode,
...@@ -79,6 +80,7 @@ import { useAtomCommand } from "../../state/use-atom-command";...@@ -79,6 +80,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
79import { ProviderModelPicker } from "../chat/ProviderModelPicker";80import { ProviderModelPicker } from "../chat/ProviderModelPicker";
80import { TraitsPicker } from "../chat/TraitsPicker";81import { TraitsPicker } from "../chat/TraitsPicker";
81import { ProjectFavicon } from "../ProjectFavicon";82import { ProjectFavicon } from "../ProjectFavicon";
83import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
82import {84import {
83 EMPTY_PROJECT_SCRIPT_INPUT,85 EMPTY_PROJECT_SCRIPT_INPUT,
84 editorRequestForScript,86 editorRequestForScript,
...@@ -480,6 +482,19 @@ function ProjectDetail({...@@ -480,6 +482,19 @@ function ProjectDetail({
480 setBooleanOverride("projectAgentBrowserAccessOverrides", enabled);482 setBooleanOverride("projectAgentBrowserAccessOverrides", enabled);
481 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });483 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
482 const projectNameEditedRef = useRef(false);484 const projectNameEditedRef = useRef(false);
485 const mergeMethodOverrides = useClientSettings(
486 (settings) => settings.pullRequestMergeMethodOverrides,
487 );
488 const projectMergeMethod = mergeMethodOverrides[group.projectKey];
489 const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => {
490 const nextOverrides = { ...mergeMethodOverrides };
491 if (method === null) {
492 delete nextOverrides[group.projectKey];
493 } else {
494 nextOverrides[group.projectKey] = method;
495 }
496 updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides });
497 };
483498
484 const faviconPath = representative.faviconPath ?? null;499 const faviconPath = representative.faviconPath ?? null;
485 const projectIcon = representative.projectIcon ?? null;500 const projectIcon = representative.projectIcon ?? null;
...@@ -983,6 +998,42 @@ function ProjectDetail({...@@ -983,6 +998,42 @@ function ProjectDetail({
983 </div>998 </div>
984 }999 }
985 />1000 />
1001 <SettingsRow
1002 title="Default merge method"
1003 description="Pull requests in this project start with this method. It overrides the last method selected."
1004 resetAction={
1005 projectMergeMethod !== undefined ? (
1006 <SettingResetButton
1007 label="project merge method"
1008 onClick={() => setProjectMergeMethod(null)}
1009 />
1010 ) : null
1011 }
1012 control={
1013 <Select
1014 value={projectMergeMethod ?? "inherit"}
1015 onValueChange={(value) =>
1016 setProjectMergeMethod(
1017 value === "inherit" ? null : (value as PullRequestMergeMethod),
1018 )
1019 }
1020 >
1021 <SelectTrigger aria-label="Default pull request merge method">
1022 <SelectValue>
1023 {projectMergeMethod === undefined
1024 ? "Last selected"
1025 : PULL_REQUEST_MERGE_METHOD_LABELS[projectMergeMethod]}
1026 </SelectValue>
1027 </SelectTrigger>
1028 <SelectPopup align="end" alignItemWithTrigger={false}>
1029 <SelectItem value="inherit">Last selected</SelectItem>
1030 <SelectItem value="merge">{PULL_REQUEST_MERGE_METHOD_LABELS.merge}</SelectItem>
1031 <SelectItem value="squash">{PULL_REQUEST_MERGE_METHOD_LABELS.squash}</SelectItem>
1032 <SelectItem value="rebase">{PULL_REQUEST_MERGE_METHOD_LABELS.rebase}</SelectItem>
1033 </SelectPopup>
1034 </Select>
1035 }
1036 />
986 <SettingsRow1037 <SettingsRow
987 title="Model"1038 title="Model"
988 status={1039 status={
apps/web/src/uiStateStore.test.ts+15
...@@ -26,6 +26,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {...@@ -26,6 +26,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
26 threadLastVisitedAtById: {},26 threadLastVisitedAtById: {},
27 threadChangedFilesExpandedById: {},27 threadChangedFilesExpandedById: {},
28 defaultAdvertisedEndpointKey: null,28 defaultAdvertisedEndpointKey: null,
29 pullRequestMergeMethod: "merge",
29 ...overrides,30 ...overrides,
30 };31 };
31}32}
...@@ -158,6 +159,18 @@ describe("uiStateStore pure functions", () => {...@@ -158,6 +159,18 @@ describe("uiStateStore pure functions", () => {
158});159});
159160
160describe("parsePersistedState", () => {161describe("parsePersistedState", () => {
162 it("hydrates the last selected pull request merge method", () => {
163 const parsed = parsePersistedState({
164 pullRequestMergeMethod: "squash",
165 });
166 const invalid = parsePersistedState({
167 pullRequestMergeMethod: "fast-forward",
168 });
169
170 expect(parsed.pullRequestMergeMethod).toBe("squash");
171 expect(invalid.pullRequestMergeMethod).toBe("merge");
172 });
173
161 it("hydrates raw UI-owned state without server entities", () => {174 it("hydrates raw UI-owned state without server entities", () => {
162 const parsed = parsePersistedState({175 const parsed = parsePersistedState({
163 projectExpandedById: {176 projectExpandedById: {
...@@ -189,6 +202,7 @@ describe("parsePersistedState", () => {...@@ -189,6 +202,7 @@ describe("parsePersistedState", () => {
189 },202 },
190 defaultAdvertisedEndpointKey: "desktop-core:lan:http",203 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
191 sidebarProjectScopeKey: null,204 sidebarProjectScopeKey: null,
205 pullRequestMergeMethod: "merge",
192 threadChangedFilesExpandedById: {206 threadChangedFilesExpandedById: {
193 "environment:thread-1": {207 "environment:thread-1": {
194 "turn-1": false,208 "turn-1": false,
...@@ -317,6 +331,7 @@ describe("uiStateStore persistence", () => {...@@ -317,6 +331,7 @@ describe("uiStateStore persistence", () => {
317 "turn-2": true,331 "turn-2": true,
318 },332 },
319 },333 },
334 pullRequestMergeMethod: "merge",
320 });335 });
321 expect(parsePersistedState(persisted)).toEqual({336 expect(parsePersistedState(persisted)).toEqual({
322 ...state,337 ...state,
apps/web/src/uiStateStore.ts+25-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Debouncer } from "@tanstack/react-pacer";1import { Debouncer } from "@tanstack/react-pacer";
2import type { PullRequestMergeMethod } from "@t3tools/contracts";
2import { create } from "zustand";3import { create } from "zustand";
3import { normalizeProjectPathForComparison } from "./lib/projectPaths";4import { normalizeProjectPathForComparison } from "./lib/projectPaths";
45
...@@ -29,6 +30,7 @@ export interface PersistedUiState {...@@ -29,6 +30,7 @@ export interface PersistedUiState {
29 sidebarProjectScopeKey?: string | null;30 sidebarProjectScopeKey?: string | null;
30 threadChangedFilesExpansionVersion?: number;31 threadChangedFilesExpansionVersion?: number;
31 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;32 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
33 pullRequestMergeMethod?: string;
32}34}
3335
34export interface UiProjectState {36export interface UiProjectState {
...@@ -49,7 +51,12 @@ export interface UiEndpointState {...@@ -49,7 +51,12 @@ export interface UiEndpointState {
49 defaultAdvertisedEndpointKey: string | null;51 defaultAdvertisedEndpointKey: string | null;
50}52}
5153
52export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {}54export interface UiPullRequestState {
55 pullRequestMergeMethod: PullRequestMergeMethod;
56}
57
58export interface UiState
59 extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {}
5360
54const initialState: UiState = {61const initialState: UiState = {
55 projectExpandedById: {},62 projectExpandedById: {},
...@@ -58,6 +65,7 @@ const initialState: UiState = {...@@ -58,6 +65,7 @@ const initialState: UiState = {
58 threadLastVisitedAtById: {},65 threadLastVisitedAtById: {},
59 threadChangedFilesExpandedById: {},66 threadChangedFilesExpandedById: {},
60 defaultAdvertisedEndpointKey: null,67 defaultAdvertisedEndpointKey: null,
68 pullRequestMergeMethod: "merge",
61};69};
6270
63const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:";71const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:";
...@@ -109,6 +117,10 @@ function sanitizeTimestampRecord(value: unknown): Record<string, string> {...@@ -109,6 +117,10 @@ function sanitizeTimestampRecord(value: unknown): Record<string, string> {
109 );117 );
110}118}
111119
120function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod {
121 return value === "merge" || value === "squash" || value === "rebase";
122}
123
112export function parsePersistedState(parsed: PersistedUiState): UiState {124export function parsePersistedState(parsed: PersistedUiState): UiState {
113 const projectExpandedById =125 const projectExpandedById =
114 parsed.projectExpandedById === undefined126 parsed.projectExpandedById === undefined
...@@ -143,6 +155,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {...@@ -143,6 +155,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {
143 : {},155 : {},
144 defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey),156 defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey),
145 sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey),157 sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey),
158 pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod)
159 ? parsed.pullRequestMergeMethod
160 : initialState.pullRequestMergeMethod,
146 };161 };
147}162}
148163
...@@ -216,6 +231,7 @@ export function persistState(state: UiState): void {...@@ -216,6 +231,7 @@ export function persistState(state: UiState): void {
216 sidebarProjectScopeKey: state.sidebarProjectScopeKey,231 sidebarProjectScopeKey: state.sidebarProjectScopeKey,
217 threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,232 threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
218 threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,233 threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,
234 pullRequestMergeMethod: state.pullRequestMergeMethod,
219 } satisfies PersistedUiState),235 } satisfies PersistedUiState),
220 );236 );
221 if (!legacyKeysCleanedUp) {237 if (!legacyKeysCleanedUp) {
...@@ -324,6 +340,12 @@ export function setSidebarProjectScopeKey(state: UiState, projectKey: string | n...@@ -324,6 +340,12 @@ export function setSidebarProjectScopeKey(state: UiState, projectKey: string | n
324 };340 };
325}341}
326342
343function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState {
344 return state.pullRequestMergeMethod === method
345 ? state
346 : { ...state, pullRequestMergeMethod: method };
347}
348
327export function resolveProjectExpanded(349export function resolveProjectExpanded(
328 projectExpandedById: Readonly<Record<string, boolean>>,350 projectExpandedById: Readonly<Record<string, boolean>>,
329 preferenceKeys: readonly string[],351 preferenceKeys: readonly string[],
...@@ -407,6 +429,7 @@ interface UiStateStore extends UiState {...@@ -407,6 +429,7 @@ interface UiStateStore extends UiState {
407 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;429 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
408 setDefaultAdvertisedEndpointKey: (key: string | null) => void;430 setDefaultAdvertisedEndpointKey: (key: string | null) => void;
409 setSidebarProjectScopeKey: (projectKey: string | null) => void;431 setSidebarProjectScopeKey: (projectKey: string | null) => void;
432 setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void;
410 setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;433 setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;
411 reorderProjects: (434 reorderProjects: (
412 currentProjectOrder: readonly string[],435 currentProjectOrder: readonly string[],
...@@ -427,6 +450,7 @@ export const useUiStateStore = create<UiStateStore>((set) => ({...@@ -427,6 +450,7 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
427 set((state) => setDefaultAdvertisedEndpointKey(state, key)),450 set((state) => setDefaultAdvertisedEndpointKey(state, key)),
428 setSidebarProjectScopeKey: (projectKey) =>451 setSidebarProjectScopeKey: (projectKey) =>
429 set((state) => setSidebarProjectScopeKey(state, projectKey)),452 set((state) => setSidebarProjectScopeKey(state, projectKey)),
453 setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)),
430 setProjectExpanded: (projectIds, expanded) =>454 setProjectExpanded: (projectIds, expanded) =>
431 set((state) => setProjectExpanded(state, projectIds, expanded)),455 set((state) => setProjectExpanded(state, projectIds, expanded)),
432 reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>456 reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>
packages/contracts/src/settings.test.ts+19
...@@ -455,6 +455,25 @@ describe("ServerSettings thread settlement", () => {...@@ -455,6 +455,25 @@ describe("ServerSettings thread settlement", () => {
455 });455 });
456});456});
457457
458describe("ClientSettings pull request merge methods", () => {
459 it("defaults to no project overrides and accepts supported methods", () => {
460 expect(decodeClientSettings({}).pullRequestMergeMethodOverrides).toEqual({});
461 expect(
462 decodeClientSettingsPatch({
463 pullRequestMergeMethodOverrides: { project: "squash" },
464 }).pullRequestMergeMethodOverrides,
465 ).toEqual({ project: "squash" });
466 });
467
468 it("rejects unsupported project merge methods", () => {
469 expect(() =>
470 decodeClientSettingsPatch({
471 pullRequestMergeMethodOverrides: { project: "fast-forward" },
472 }),
473 ).toThrow();
474 });
475});
476
458describe("ServerSettings.providerInstances (slice-2 invariant)", () => {477describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
459 it("defaults text generation to Luna at low reasoning effort", () => {478 it("defaults text generation to Luna at low reasoning effort", () => {
460 expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({479 expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({
packages/contracts/src/settings.ts+8
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32 ProviderInstanceId,32 ProviderInstanceId,
33 type ProviderDriverKind,33 type ProviderDriverKind,
34} from "./providerInstance.ts";34} from "./providerInstance.ts";
35import { PullRequestMergeMethod } from "./pullRequest.ts";
3536
36// ── Client Settings (local-only) ───────────────────────────────37// ── Client Settings (local-only) ───────────────────────────────
3738
...@@ -391,6 +392,10 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -391,6 +392,10 @@ export const ClientSettingsSchema = Schema.Struct({
391 modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),392 modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
392 }),393 }),
393 ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),394 ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
395 pullRequestMergeMethodOverrides: Schema.Record(
396 TrimmedNonEmptyString,
397 PullRequestMergeMethod,
398 ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
394 // Legacy plan mode. The composer's Build/Plan toggle was removed from the399 // Legacy plan mode. The composer's Build/Plan toggle was removed from the
395 // default UI; this beta flag restores it (plus the /plan and /default slash400 // default UI; this beta flag restores it (plus the /plan and /default slash
396 // commands) for users who still rely on the old workflow.401 // commands) for users who still rely on the old workflow.
...@@ -1341,6 +1346,9 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1341,6 +1346,9 @@ export const ClientSettingsPatch = Schema.Struct({
1341 }),1346 }),
1342 ),1347 ),
1343 ),1348 ),
1349 pullRequestMergeMethodOverrides: Schema.optionalKey(
1350 Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod),
1351 ),
1344 planModeEnabled: Schema.optionalKey(Schema.Boolean),1352 planModeEnabled: Schema.optionalKey(Schema.Boolean),
1345 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),1353 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
1346 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),1354 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),