diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 731a3acecef4..fb49a71a21a7 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -50,7 +50,12 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useClientSettings } from "~/hooks/useSettings"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "~/logicalProject"; import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; @@ -64,6 +69,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { useUiStateStore } from "~/uiStateStore"; import { AlertDialog, @@ -114,8 +120,10 @@ import { pullRequestComposerTarget, pullRequestFindingKey, pullRequestHandoffLabels, + PULL_REQUEST_MERGE_METHOD_LABELS, readableFailure, resolveBaseFreshness, + resolvePullRequestMergeMethod, type PullRequestFinding, shouldRefreshPullRequestActivity, } from "./pullRequestDetail.logic"; @@ -153,12 +161,6 @@ const ACTION_SUCCESS_LABELS: Record = { "disable-auto-merge": "Auto-merge turned off", }; -const MERGE_METHOD_LABELS: Record = { - merge: "Merge", - squash: "Squash", - rebase: "Rebase", -}; - /** Said as the thing that did not happen, rather than as the operation that returned an error. */ const ACTION_FAILURE_LABELS: Record = { merge: "Could not merge this pull request", @@ -453,7 +455,16 @@ export function PullRequestDetailPanel({ compensationRef.current = null; if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); }, [condensed]); - const [mergeMethod, setMergeMethod] = useState("merge"); + const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); + const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); + const mergeMethodOverrides = useClientSettings( + (settings) => settings.pullRequestMergeMethodOverrides, + ); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const [mergeMethodScope, setMergeMethodScope] = useState<{ + pullRequestKey: string; + method: PullRequestMergeMethod; + } | null>(null); const [confirmation, setConfirmation] = useState<{ readonly open: boolean; readonly action: "merge" | "close" | "enable-auto-merge"; @@ -590,6 +601,15 @@ export function PullRequestDetailPanel({ const newThread = useNewThreadHandler(); const { environments } = useEnvironments(); const projects = useProjects(); + const mergeMethodProject = projects.find( + (project) => project.environmentId === environmentId && project.id === reference.projectId, + ); + const mergeMethodProjectKey = mergeMethodProject + ? deriveLogicalProjectKeyFromSettings(mergeMethodProject, projectGroupingSettings) + : null; + const projectDefaultMergeMethod = mergeMethodProjectKey + ? mergeMethodOverrides[mergeMethodProjectKey] + : undefined; // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and // the thread is already on one server's copy of the branch. const pickableEnvironments = useMemo( @@ -1022,10 +1042,15 @@ export function PullRequestDetailPanel({ const allowedMergeMethods = detail ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) : []; - const selectedMergeMethod = allowedMergeMethods.includes(mergeMethod) - ? mergeMethod - : (allowedMergeMethods[0] ?? "merge"); - const selectedMergeMethodLabel = MERGE_METHOD_LABELS[selectedMergeMethod]; + const currentMergeMethod = + mergeMethodScope?.pullRequestKey === pullRequestKey ? mergeMethodScope.method : null; + const selectedMergeMethod = resolvePullRequestMergeMethod( + allowedMergeMethods, + currentMergeMethod, + projectDefaultMergeMethod, + lastSelectedMergeMethod, + ); + const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; // Only an outright yes arms it. A host that reports nothing has not said the merge is already // spoken for, and an off switch for something that may not be on says the wrong thing twice. @@ -1418,17 +1443,24 @@ export function PullRequestDetailPanel({ {showsDraftToggle ? : null} - setMergeMethod(method as PullRequestMergeMethod) - } + onValueChange={(method) => { + const selectedMethod = method as PullRequestMergeMethod; + setMergeMethodScope({ pullRequestKey, method: selectedMethod }); + setLastSelectedMergeMethod(selectedMethod); + }} > {allowedMergeMethods.map((method) => ( - + {/* The radio item lays its children out as one block, so the icon and the label need their own row to share a line. */} - {MERGE_METHOD_LABELS[method]} + {PULL_REQUEST_MERGE_METHOD_LABELS[method]} ))} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 9b9ef610752b..efa3579eeebf 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -32,6 +32,7 @@ import { readableFailure, shouldRefreshPullRequestActivity, resolveBaseFreshness, + resolvePullRequestMergeMethod, buildPullRequestTimeline, describePullRequestState, editPullRequestThreadComment, @@ -63,6 +64,21 @@ const TIMELINE_SOURCE: Pick< closedAt: null, }; +describe("pull request merge method", () => { + it("uses the current choice, then the project default, then the last choice", () => { + expect( + resolvePullRequestMergeMethod(["merge", "squash", "rebase"], null, "squash", "rebase"), + ).toBe("squash"); + expect( + resolvePullRequestMergeMethod(["merge", "squash", "rebase"], "rebase", "squash", "merge"), + ).toBe("rebase"); + expect(resolvePullRequestMergeMethod(["merge", "rebase"], null, "squash", "rebase")).toBe( + "rebase", + ); + expect(resolvePullRequestMergeMethod(["squash"], null, "merge", "rebase")).toBe("squash"); + }); +}); + describe("pull request activity refresh", () => { const first = { key: "project:acme/web#7", diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index a616a8395239..54d24b54ebc1 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -7,6 +7,7 @@ import type { PullRequestCommit, PullRequestDetailView, PullRequestMergeability, + PullRequestMergeMethod, PullRequestReaction, PullRequestReviewThread, PullRequestState, @@ -16,6 +17,24 @@ import type { import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; +export const PULL_REQUEST_MERGE_METHOD_LABELS: Record = { + merge: "Merge", + squash: "Squash and merge", + rebase: "Rebase and merge", +}; + +export function resolvePullRequestMergeMethod( + allowed: ReadonlyArray, + current: PullRequestMergeMethod | null, + projectDefault: PullRequestMergeMethod | undefined, + lastSelected: PullRequestMergeMethod, +): PullRequestMergeMethod { + for (const method of [current, projectDefault, lastSelected]) { + if (method && allowed.includes(method)) return method; + } + return allowed[0] ?? "merge"; +} + /** Activity changes only when the same host resource reports a newer revision. */ export function shouldRefreshPullRequestActivity( previous: { readonly key: string; readonly updatedAt: string } | null, diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 6047b8fc48dc..4fb5f1fd1165 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -16,6 +16,7 @@ import type { ContextMenuItem, ModelSelection, ProviderDriverKind, + PullRequestMergeMethod, SidebarProjectGroupingMode, T3ProjectFileScript, ThreadEnvMode, @@ -74,6 +75,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { ProjectFavicon } from "../ProjectFavicon"; +import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, @@ -294,6 +296,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const settings = usePrimarySettings(); const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const mergeMethodOverrides = useClientSettings( + (settings) => settings.pullRequestMergeMethodOverrides, + ); const serverProviders = useAtomValue(primaryServerProvidersAtom); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); @@ -323,6 +328,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { group.memberProjects.find( (member) => member.environmentId === group.environmentId && member.id === group.id, ) ?? group.memberProjects[0]!; + const projectMergeMethod = mergeMethodOverrides[group.projectKey]; + const setProjectMergeMethod = useCallback( + (method: PullRequestMergeMethod | null) => { + const nextOverrides = { ...mergeMethodOverrides }; + if (method === null) { + delete nextOverrides[group.projectKey]; + } else { + nextOverrides[group.projectKey] = method; + } + updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides }); + }, + [group.projectKey, mergeMethodOverrides, updateClientSettings], + ); + const faviconPath = representative.faviconPath ?? null; const pickProjectFavicon = typeof window !== "undefined" && @@ -809,6 +828,44 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } /> + setProjectMergeMethod(null)} + /> + ) : null + } + control={ + + } + /> diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 304502873539..fabaf5fab657 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -24,6 +24,7 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + pullRequestMergeMethod: "merge", ...overrides, }; } @@ -147,6 +148,18 @@ describe("uiStateStore pure functions", () => { }); describe("parsePersistedState", () => { + it("hydrates the last selected pull request merge method", () => { + const parsed = parsePersistedState({ + pullRequestMergeMethod: "squash", + }); + const invalid = parsePersistedState({ + pullRequestMergeMethod: "fast-forward", + }); + + expect(parsed.pullRequestMergeMethod).toBe("squash"); + expect(invalid.pullRequestMergeMethod).toBe("merge"); + }); + it("hydrates raw UI-owned state without server entities", () => { const parsed = parsePersistedState({ projectExpandedById: { @@ -177,6 +190,7 @@ describe("parsePersistedState", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + pullRequestMergeMethod: "merge", threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, @@ -303,6 +317,7 @@ describe("uiStateStore persistence", () => { "turn-2": true, }, }, + pullRequestMergeMethod: "merge", }); expect(parsePersistedState(persisted)).toEqual({ ...state, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 5d744d540a5a..dbf7c4bd48ef 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,4 +1,5 @@ import { Debouncer } from "@tanstack/react-pacer"; +import type { PullRequestMergeMethod } from "@t3tools/contracts"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; @@ -27,6 +28,7 @@ export interface PersistedUiState { defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION; threadChangedFilesExpandedById?: Record>; + pullRequestMergeMethod?: string; } export interface UiProjectState { @@ -43,7 +45,12 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiPullRequestState { + pullRequestMergeMethod: PullRequestMergeMethod; +} + +export interface UiState + extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {} const initialState: UiState = { projectExpandedById: {}, @@ -51,6 +58,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + pullRequestMergeMethod: "merge", }; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; @@ -98,6 +106,10 @@ function sanitizeTimestampRecord(value: unknown): Record { ); } +function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod { + return value === "merge" || value === "squash" || value === "rebase"; +} + export function parsePersistedState(parsed: PersistedUiState): UiState { const projectExpandedById = parsed.projectExpandedById === undefined @@ -135,6 +147,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { parsed.defaultAdvertisedEndpointKey.length > 0 ? parsed.defaultAdvertisedEndpointKey : null, + pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod) + ? parsed.pullRequestMergeMethod + : initialState.pullRequestMergeMethod, }; } @@ -207,6 +222,7 @@ export function persistState(state: UiState): void { defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, + pullRequestMergeMethod: state.pullRequestMergeMethod, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -304,6 +320,12 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } +export function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState { + return state.pullRequestMergeMethod === method + ? state + : { ...state, pullRequestMergeMethod: method }; +} + export function resolveProjectExpanded( projectExpandedById: Readonly>, preferenceKeys: readonly string[], @@ -386,6 +408,7 @@ interface UiStateStore extends UiState { markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; + setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -404,6 +427,7 @@ export const useUiStateStore = create((set) => ({ set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), + setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..7393dbd88c63 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -131,6 +131,25 @@ describe("ClientSettings sidebar", () => { }); }); +describe("ClientSettings pull request merge methods", () => { + it("defaults to no project overrides and accepts supported methods", () => { + expect(decodeClientSettings({}).pullRequestMergeMethodOverrides).toEqual({}); + expect( + decodeClientSettingsPatch({ + pullRequestMergeMethodOverrides: { project: "squash" }, + }).pullRequestMergeMethodOverrides, + ).toEqual({ project: "squash" }); + }); + + it("rejects unsupported project merge methods", () => { + expect(() => + decodeClientSettingsPatch({ + pullRequestMergeMethodOverrides: { project: "fast-forward" }, + }), + ).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 80e03b8c879e..9ff546179275 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,6 +23,7 @@ import { ProviderInstanceId, type ProviderDriverKind, } from "./providerInstance.ts"; +import { PullRequestMergeMethod } from "./pullRequest.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -221,6 +222,10 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + pullRequestMergeMethodOverrides: Schema.Record( + TrimmedNonEmptyString, + PullRequestMergeMethod, + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // Legacy plan mode. The composer's Build/Plan toggle was removed from the // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. @@ -913,6 +918,9 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + pullRequestMergeMethodOverrides: Schema.optionalKey( + Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod), + ), planModeEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),