| 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 | 64 | legacySidebarEnabled: false, |
| 65 | 65 | loadBalancingEnabled: false, |
| 66 | 66 | loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, |
| 67 | pullRequestMergeMethodOverrides: {}, | |
| 67 | 68 | timestampFormat: "24-hour", |
| 68 | 69 | wordWrap: true, |
| 69 | 70 | }; |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+65-20| ... | ... | @@ -53,14 +53,21 @@ import { |
| 53 | 53 | |
| 54 | 54 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 55 | 55 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 56 | import { useClientSettings } from "~/hooks/useSettings"; | |
| 56 | 57 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 58 | import { | |
| 59 | deriveLogicalProjectKeyFromSettings, | |
| 60 | derivePhysicalProjectKey, | |
| 61 | selectProjectGroupingSettings, | |
| 62 | } from "~/logicalProject"; | |
| 57 | 63 | import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; |
| 58 | 64 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 59 | 65 | import { cn } from "~/lib/utils"; |
| 60 | 66 | import { readLocalApi } from "~/localApi"; |
| 61 | 67 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 68 | import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; | |
| 62 | 69 | import { useProjects } from "~/state/entities"; |
| 63 | import { useEnvironments } from "~/state/environments"; | |
| 70 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | |
| 64 | 71 | import { useEnvironmentQuery } from "~/state/query"; |
| 65 | 72 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 66 | 73 | import { |
| ... | ... | @@ -71,6 +78,7 @@ import { |
| 71 | 78 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 72 | 79 | import { vcsEnvironment } from "~/state/vcs"; |
| 73 | 80 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 81 | import { useUiStateStore } from "~/uiStateStore"; | |
| 74 | 82 | |
| 75 | 83 | import { |
| 76 | 84 | AlertDialog, |
| ... | ... | @@ -124,11 +132,13 @@ import { |
| 124 | 132 | pullRequestCheckoutCommand, |
| 125 | 133 | pullRequestFindingKey, |
| 126 | 134 | pullRequestHandoffLabels, |
| 135 | PULL_REQUEST_MERGE_METHOD_LABELS, | |
| 127 | 136 | readableFailure, |
| 128 | 137 | readPullRequestDetailSnapshot, |
| 129 | 138 | resolveDisplayedPullRequestDetail, |
| 130 | 139 | resolvePullRequestPrimaryControl, |
| 131 | 140 | resolveBaseFreshness, |
| 141 | resolvePullRequestMergeMethod, | |
| 132 | 142 | type PullRequestFinding, |
| 133 | 143 | shouldRefreshPullRequestActivity, |
| 134 | 144 | writePullRequestDetailSnapshot, |
| ... | ... | @@ -168,12 +178,6 @@ const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = { |
| 168 | 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 | 181 | /** Said as the thing that did not happen, rather than as the operation that returned an error. */ |
| 178 | 182 | const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = { |
| 179 | 183 | merge: "Could not merge this pull request", |
| ... | ... | @@ -553,12 +557,16 @@ export function PullRequestDetailPanel({ |
| 553 | 557 | compensationRef.current = null; |
| 554 | 558 | if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); |
| 555 | 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 | 566 | const [mergeMethodSelection, setMergeMethodSelection] = useState<{ |
| 557 | 567 | readonly pullRequestKey: string; |
| 558 | 568 | readonly method: PullRequestMergeMethod; |
| 559 | }>(() => ({ pullRequestKey, method: "merge" })); | |
| 560 | const mergeMethod = | |
| 561 | mergeMethodSelection.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : "merge"; | |
| 569 | } | null>(null); | |
| 562 | 570 | const setMergeMethod = (method: PullRequestMergeMethod) => { |
| 563 | 571 | setMergeMethodSelection({ pullRequestKey, method }); |
| 564 | 572 | }; |
| ... | ... | @@ -763,6 +771,7 @@ export function PullRequestDetailPanel({ |
| 763 | 771 | const [titleSaving, setTitleSaving] = useState(false); |
| 764 | 772 | const newThread = useNewThreadHandler(); |
| 765 | 773 | const { environments } = useEnvironments(); |
| 774 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 766 | 775 | const projects = useProjects(); |
| 767 | 776 | const unavailableGitHubUrl = useMemo(() => { |
| 768 | 777 | const identity = projects.find( |
| ... | ... | @@ -770,6 +779,30 @@ export function PullRequestDetailPanel({ |
| 770 | 779 | )?.repositoryIdentity; |
| 771 | 780 | return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); |
| 772 | 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 | 806 | // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and |
| 774 | 807 | // the thread is already on one server's copy of the branch. |
| 775 | 808 | const pickableEnvironments = useMemo( |
| ... | ... | @@ -1234,10 +1267,15 @@ export function PullRequestDetailPanel({ |
| 1234 | 1267 | const allowedMergeMethods = detail |
| 1235 | 1268 | ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) |
| 1236 | 1269 | : []; |
| 1237 | const selectedMergeMethod = allowedMergeMethods.includes(mergeMethod) | |
| 1238 | ? mergeMethod | |
| 1239 | : (allowedMergeMethods[0] ?? "merge"); | |
| 1240 | const selectedMergeMethodLabel = MERGE_METHOD_LABELS[selectedMergeMethod]; | |
| 1270 | const currentMergeMethod = | |
| 1271 | mergeMethodSelection?.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : null; | |
| 1272 | const selectedMergeMethod = resolvePullRequestMergeMethod( | |
| 1273 | allowedMergeMethods, | |
| 1274 | currentMergeMethod, | |
| 1275 | projectDefaultMergeMethod, | |
| 1276 | lastSelectedMergeMethod, | |
| 1277 | ); | |
| 1278 | const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; | |
| 1241 | 1279 | const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`; |
| 1242 | 1280 | const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; |
| 1243 | 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 | 1283 | const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true; |
| 1246 | 1284 | const armedMergeMethod = detail?.autoMergeMethod; |
| 1247 | 1285 | const armedAutoMergeLabel = armedMergeMethod |
| 1248 | ? `Auto-merge (${MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})` | |
| 1286 | ? `Auto-merge (${PULL_REQUEST_MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})` | |
| 1249 | 1287 | : "Auto-merge"; |
| 1250 | 1288 | const workflowApprovalsRequired = |
| 1251 | 1289 | detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0; |
| ... | ... | @@ -1783,17 +1821,24 @@ export function PullRequestDetailPanel({ |
| 1783 | 1821 | ) : null} |
| 1784 | 1822 | <MenuRadioGroup |
| 1785 | 1823 | value={selectedMergeMethod} |
| 1786 | onValueChange={(method) => | |
| 1787 | setMergeMethod(method as PullRequestMergeMethod) | |
| 1788 | } | |
| 1824 | onValueChange={(method) => { | |
| 1825 | const selectedMethod = method as PullRequestMergeMethod; | |
| 1826 | setMergeMethod(selectedMethod); | |
| 1827 | setLastSelectedMergeMethod(selectedMethod); | |
| 1828 | }} | |
| 1789 | 1829 | > |
| 1790 | 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 | 1837 | {/* The radio item lays its children out as one block, so the |
| 1793 | 1838 | icon and the label need their own row to share a line. */} |
| 1794 | 1839 | <span className="flex min-w-0 items-center gap-2"> |
| 1795 | 1840 | <GitMergeIcon className="size-3.5" /> |
| 1796 | <span>{MERGE_METHOD_LABELS[method]}</span> | |
| 1841 | <span>{PULL_REQUEST_MERGE_METHOD_LABELS[method]}</span> | |
| 1797 | 1842 | </span> |
| 1798 | 1843 | </MenuRadioItem> |
| 1799 | 1844 | ))} |
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+16| ... | ... | @@ -37,6 +37,7 @@ import { |
| 37 | 37 | resolvePullRequestPrimaryControl, |
| 38 | 38 | shouldRefreshPullRequestActivity, |
| 39 | 39 | resolveBaseFreshness, |
| 40 | resolvePullRequestMergeMethod, | |
| 40 | 41 | buildPullRequestTimeline, |
| 41 | 42 | editPullRequestThreadComment, |
| 42 | 43 | writePullRequestDetailSnapshot, |
| ... | ... | @@ -85,6 +86,21 @@ const TIMELINE_SOURCE: Pick< |
| 85 | 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 | 104 | describe("pull request activity refresh", () => { |
| 89 | 105 | const first = { |
| 90 | 106 | key: "project:acme/web#7", |
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+19| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | type PullRequestCommit, |
| 12 | 12 | type PullRequestDetailView, |
| 13 | 13 | type PullRequestMergeability, |
| 14 | type PullRequestMergeMethod, | |
| 14 | 15 | type PullRequestReaction, |
| 15 | 16 | type PullRequestReviewThread, |
| 16 | 17 | type PullRequestState, |
| ... | ... | @@ -21,6 +22,24 @@ import { |
| 21 | 22 | |
| 22 | 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 | 43 | const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; |
| 25 | 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 | 21 | type ResolvedKeybindingsConfig, |
| 22 | 22 | type ServerSettings, |
| 23 | 23 | type ProviderDriverKind, |
| 24 | type PullRequestMergeMethod, | |
| 24 | 25 | type SidebarProjectGroupingMode, |
| 25 | 26 | type T3ProjectFileScript, |
| 26 | 27 | type ThreadEnvMode, |
| ... | ... | @@ -79,6 +80,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; |
| 79 | 80 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 80 | 81 | import { TraitsPicker } from "../chat/TraitsPicker"; |
| 81 | 82 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 83 | import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; | |
| 82 | 84 | import { |
| 83 | 85 | EMPTY_PROJECT_SCRIPT_INPUT, |
| 84 | 86 | editorRequestForScript, |
| ... | ... | @@ -480,6 +482,19 @@ function ProjectDetail({ |
| 480 | 482 | setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); |
| 481 | 483 | const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); |
| 482 | 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 | 499 | const faviconPath = representative.faviconPath ?? null; |
| 485 | 500 | const projectIcon = representative.projectIcon ?? null; |
| ... | ... | @@ -983,6 +998,42 @@ function ProjectDetail({ |
| 983 | 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 | 1037 | <SettingsRow |
| 987 | 1038 | title="Model" |
| 988 | 1039 | status={ |
apps/web/src/uiStateStore.test.ts+15| ... | ... | @@ -26,6 +26,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState { |
| 26 | 26 | threadLastVisitedAtById: {}, |
| 27 | 27 | threadChangedFilesExpandedById: {}, |
| 28 | 28 | defaultAdvertisedEndpointKey: null, |
| 29 | pullRequestMergeMethod: "merge", | |
| 29 | 30 | ...overrides, |
| 30 | 31 | }; |
| 31 | 32 | } |
| ... | ... | @@ -158,6 +159,18 @@ describe("uiStateStore pure functions", () => { |
| 158 | 159 | }); |
| 159 | 160 | |
| 160 | 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 | 174 | it("hydrates raw UI-owned state without server entities", () => { |
| 162 | 175 | const parsed = parsePersistedState({ |
| 163 | 176 | projectExpandedById: { |
| ... | ... | @@ -189,6 +202,7 @@ describe("parsePersistedState", () => { |
| 189 | 202 | }, |
| 190 | 203 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 191 | 204 | sidebarProjectScopeKey: null, |
| 205 | pullRequestMergeMethod: "merge", | |
| 192 | 206 | threadChangedFilesExpandedById: { |
| 193 | 207 | "environment:thread-1": { |
| 194 | 208 | "turn-1": false, |
| ... | ... | @@ -317,6 +331,7 @@ describe("uiStateStore persistence", () => { |
| 317 | 331 | "turn-2": true, |
| 318 | 332 | }, |
| 319 | 333 | }, |
| 334 | pullRequestMergeMethod: "merge", | |
| 320 | 335 | }); |
| 321 | 336 | expect(parsePersistedState(persisted)).toEqual({ |
| 322 | 337 | ...state, |
apps/web/src/uiStateStore.ts+25-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { Debouncer } from "@tanstack/react-pacer"; |
| 2 | import type { PullRequestMergeMethod } from "@t3tools/contracts"; | |
| 2 | 3 | import { create } from "zustand"; |
| 3 | 4 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; |
| 4 | 5 | |
| ... | ... | @@ -29,6 +30,7 @@ export interface PersistedUiState { |
| 29 | 30 | sidebarProjectScopeKey?: string | null; |
| 30 | 31 | threadChangedFilesExpansionVersion?: number; |
| 31 | 32 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; |
| 33 | pullRequestMergeMethod?: string; | |
| 32 | 34 | } |
| 33 | 35 | |
| 34 | 36 | export interface UiProjectState { |
| ... | ... | @@ -49,7 +51,12 @@ export interface UiEndpointState { |
| 49 | 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 | 61 | const initialState: UiState = { |
| 55 | 62 | projectExpandedById: {}, |
| ... | ... | @@ -58,6 +65,7 @@ const initialState: UiState = { |
| 58 | 65 | threadLastVisitedAtById: {}, |
| 59 | 66 | threadChangedFilesExpandedById: {}, |
| 60 | 67 | defaultAdvertisedEndpointKey: null, |
| 68 | pullRequestMergeMethod: "merge", | |
| 61 | 69 | }; |
| 62 | 70 | |
| 63 | 71 | const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; |
| ... | ... | @@ -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 | 124 | export function parsePersistedState(parsed: PersistedUiState): UiState { |
| 113 | 125 | const projectExpandedById = |
| 114 | 126 | parsed.projectExpandedById === undefined |
| ... | ... | @@ -143,6 +155,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { |
| 143 | 155 | : {}, |
| 144 | 156 | defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey), |
| 145 | 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 | 231 | sidebarProjectScopeKey: state.sidebarProjectScopeKey, |
| 217 | 232 | threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, |
| 218 | 233 | threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, |
| 234 | pullRequestMergeMethod: state.pullRequestMergeMethod, | |
| 219 | 235 | } satisfies PersistedUiState), |
| 220 | 236 | ); |
| 221 | 237 | if (!legacyKeysCleanedUp) { |
| ... | ... | @@ -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 | 349 | export function resolveProjectExpanded( |
| 328 | 350 | projectExpandedById: Readonly<Record<string, boolean>>, |
| 329 | 351 | preferenceKeys: readonly string[], |
| ... | ... | @@ -407,6 +429,7 @@ interface UiStateStore extends UiState { |
| 407 | 429 | setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; |
| 408 | 430 | setDefaultAdvertisedEndpointKey: (key: string | null) => void; |
| 409 | 431 | setSidebarProjectScopeKey: (projectKey: string | null) => void; |
| 432 | setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void; | |
| 410 | 433 | setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; |
| 411 | 434 | reorderProjects: ( |
| 412 | 435 | currentProjectOrder: readonly string[], |
| ... | ... | @@ -427,6 +450,7 @@ export const useUiStateStore = create<UiStateStore>((set) => ({ |
| 427 | 450 | set((state) => setDefaultAdvertisedEndpointKey(state, key)), |
| 428 | 451 | setSidebarProjectScopeKey: (projectKey) => |
| 429 | 452 | set((state) => setSidebarProjectScopeKey(state, projectKey)), |
| 453 | setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)), | |
| 430 | 454 | setProjectExpanded: (projectIds, expanded) => |
| 431 | 455 | set((state) => setProjectExpanded(state, projectIds, expanded)), |
| 432 | 456 | reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => |
packages/contracts/src/settings.test.ts+19| ... | ... | @@ -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 | 477 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 459 | 478 | it("defaults text generation to Luna at low reasoning effort", () => { |
| 460 | 479 | expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ |
packages/contracts/src/settings.ts+8| ... | ... | @@ -32,6 +32,7 @@ import { |
| 32 | 32 | ProviderInstanceId, |
| 33 | 33 | type ProviderDriverKind, |
| 34 | 34 | } from "./providerInstance.ts"; |
| 35 | import { PullRequestMergeMethod } from "./pullRequest.ts"; | |
| 35 | 36 | |
| 36 | 37 | // ── Client Settings (local-only) ─────────────────────────────── |
| 37 | 38 | |
| ... | ... | @@ -391,6 +392,10 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 391 | 392 | modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 392 | 393 | }), |
| 393 | 394 | ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 395 | pullRequestMergeMethodOverrides: Schema.Record( | |
| 396 | TrimmedNonEmptyString, | |
| 397 | PullRequestMergeMethod, | |
| 398 | ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 394 | 399 | // Legacy plan mode. The composer's Build/Plan toggle was removed from the |
| 395 | 400 | // default UI; this beta flag restores it (plus the /plan and /default slash |
| 396 | 401 | // commands) for users who still rely on the old workflow. |
| ... | ... | @@ -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 | 1352 | planModeEnabled: Schema.optionalKey(Schema.Boolean), |
| 1345 | 1353 | contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), |
| 1346 | 1354 | composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), |