| author | |
| committer | |
| log | 7d9aaf6a7eb22d5e85b0e3b21686cc437fe68576 |
| tree | 643dcd07672424ea5ea5e5bf41bcd9dda6a07082 |
| parent | 430fbd1ffc98378f6a6a41027ddc6004a79b2e7b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 53 | 53 | ||
| 54 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 54 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 55 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; | 55 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 56 | import { useClientSettings } from "~/hooks/useSettings"; | ||
| 56 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 57 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 58 | import { | ||
| 59 | deriveLogicalProjectKeyFromSettings, | ||
| 60 | derivePhysicalProjectKey, | ||
| 61 | selectProjectGroupingSettings, | ||
| 62 | } from "~/logicalProject"; | ||
| 57 | import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; | 63 | import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; |
| 58 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; | 64 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 59 | import { cn } from "~/lib/utils"; | 65 | import { cn } from "~/lib/utils"; |
| 60 | import { readLocalApi } from "~/localApi"; | 66 | import { readLocalApi } from "~/localApi"; |
| 61 | import type { ReviewCommentContext } from "~/reviewCommentContext"; | 67 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 68 | import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; | ||
| 62 | import { useProjects } from "~/state/entities"; | 69 | import { useProjects } from "~/state/entities"; |
| 63 | import { useEnvironments } from "~/state/environments"; | 70 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 64 | import { useEnvironmentQuery } from "~/state/query"; | 71 | import { useEnvironmentQuery } from "~/state/query"; |
| 65 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; | 72 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 66 | import { | 73 | import { |
| ... | @@ -71,6 +78,7 @@ import { | ... | @@ -71,6 +78,7 @@ import { |
| 71 | import { useAtomCommand } from "~/state/use-atom-command"; | 78 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 72 | import { vcsEnvironment } from "~/state/vcs"; | 79 | import { vcsEnvironment } from "~/state/vcs"; |
| 73 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 80 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 81 | import { useUiStateStore } from "~/uiStateStore"; | ||
| 74 | 82 | ||
| 75 | import { | 83 | import { |
| 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 | }; |
| 170 | 180 | ||
| 171 | const 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. */ |
| 178 | const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = { | 182 | const 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, and | 806 | // 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 = detail | 1267 | 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 | ? mergeMethod | 1271 | 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 already | 1281 | // 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 = armedMergeMethod | 1285 | 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 | <MenuRadioGroup | 1822 | <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 the | 1837 | {/* 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 | }; |
| 87 | 88 | ||
| 89 | describe("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 | |||
| 88 | describe("pull request activity refresh", () => { | 104 | describe("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 { |
| 21 | 22 | ||
| 22 | import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; | 23 | import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; |
| 23 | 24 | ||
| 25 | export const PULL_REQUEST_MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = { | ||
| 26 | merge: "Merge", | ||
| 27 | squash: "Squash and merge", | ||
| 28 | rebase: "Rebase and merge", | ||
| 29 | }; | ||
| 30 | |||
| 31 | export 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 | |||
| 24 | const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; | 43 | const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; |
| 25 | const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | 44 | const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; |
| 26 | 45 |
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"; |
| 79 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; | 80 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 80 | import { TraitsPicker } from "../chat/TraitsPicker"; | 81 | import { TraitsPicker } from "../chat/TraitsPicker"; |
| 81 | import { ProjectFavicon } from "../ProjectFavicon"; | 82 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 83 | import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; | ||
| 82 | import { | 84 | import { |
| 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 | }; | ||
| 483 | 498 | ||
| 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 | <SettingsRow | 1037 | <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 | }); |
| 159 | 160 | ||
| 160 | describe("parsePersistedState", () => { | 161 | describe("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 @@ |
| 1 | import { Debouncer } from "@tanstack/react-pacer"; | 1 | import { Debouncer } from "@tanstack/react-pacer"; |
| 2 | import type { PullRequestMergeMethod } from "@t3tools/contracts"; | ||
| 2 | import { create } from "zustand"; | 3 | import { create } from "zustand"; |
| 3 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; | 4 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; |
| 4 | 5 | ||
| ... | @@ -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 | } |
| 33 | 35 | ||
| 34 | export interface UiProjectState { | 36 | export 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 | } |
| 51 | 53 | ||
| 52 | export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} | 54 | export interface UiPullRequestState { |
| 55 | pullRequestMergeMethod: PullRequestMergeMethod; | ||
| 56 | } | ||
| 57 | |||
| 58 | export interface UiState | ||
| 59 | extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {} | ||
| 53 | 60 | ||
| 54 | const initialState: UiState = { | 61 | const 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 | }; |
| 62 | 70 | ||
| 63 | const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; | 71 | const 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 | } |
| 111 | 119 | ||
| 120 | function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod { | ||
| 121 | return value === "merge" || value === "squash" || value === "rebase"; | ||
| 122 | } | ||
| 123 | |||
| 112 | export function parsePersistedState(parsed: PersistedUiState): UiState { | 124 | export function parsePersistedState(parsed: PersistedUiState): UiState { |
| 113 | const projectExpandedById = | 125 | const projectExpandedById = |
| 114 | parsed.projectExpandedById === undefined | 126 | 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 | } |
| 148 | 163 | ||
| ... | @@ -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 | } |
| 326 | 342 | ||
| 343 | function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState { | ||
| 344 | return state.pullRequestMergeMethod === method | ||
| 345 | ? state | ||
| 346 | : { ...state, pullRequestMergeMethod: method }; | ||
| 347 | } | ||
| 348 | |||
| 327 | export function resolveProjectExpanded( | 349 | export 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 | }); |
| 457 | 457 | ||
| 458 | describe("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 | |||
| 458 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { | 477 | describe("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"; |
| 35 | import { PullRequestMergeMethod } from "./pullRequest.ts"; | ||
| 35 | 36 | ||
| 36 | // ── Client Settings (local-only) ─────────────────────────────── | 37 | // ── Client Settings (local-only) ─────────────────────────────── |
| 37 | 38 | ||
| ... | @@ -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 the | 399 | // 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 slash | 400 | // 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), |