From dda0d30afb4804101863f0bcac8b16e12e2ab299 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:18:25 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20default=20vs=20cus?= =?UTF-8?q?tom=20trunk=20branch=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with --- .../components/ChatInput/CreationControls.tsx | 55 ++++++++++++---- src/browser/components/ChatInput/index.tsx | 6 +- .../ChatInput/useCreationWorkspace.ts | 9 ++- .../hooks/useDraftWorkspaceSettings.ts | 63 ++++++++++++++++--- .../hooks/useStartWorkspaceCreation.test.ts | 4 ++ .../hooks/useStartWorkspaceCreation.ts | 8 ++- src/common/constants/storage.ts | 8 +++ src/common/constants/workspace.ts | 12 ++++ 8 files changed, 140 insertions(+), 25 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index dd231843b..fb219407d 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -2,11 +2,14 @@ import React from "react"; import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; import { TooltipWrapper, Tooltip } from "../Tooltip"; import { Select } from "../Select"; +import { DEFAULT_TRUNK_BRANCH, TRUNK_SELECTION, type TrunkSelection } from "@/common/constants/workspace"; interface CreationControlsProps { branches: string[]; - trunkBranch: string; - onTrunkBranchChange: (branch: string) => void; + trunkSelection: TrunkSelection; + customTrunkBranch: string; + onTrunkSelectionChange: (selection: TrunkSelection) => void; + onCustomTrunkBranchChange: (branch: string) => void; runtimeMode: RuntimeMode; sshHost: string; onRuntimeChange: (mode: RuntimeMode, host: string) => void; @@ -19,24 +22,52 @@ interface CreationControlsProps { * - Runtime mode (local vs SSH) */ export function CreationControls(props: CreationControlsProps) { + const defaultUnavailable = + props.branches.length > 0 && !props.branches.includes(DEFAULT_TRUNK_BRANCH); + const showCustomPicker = props.trunkSelection === TRUNK_SELECTION.CUSTOM; + + const handleTrunkSelectionChange = (value: string) => { + const selection = value as TrunkSelection; + if (selection === TRUNK_SELECTION.DEFAULT && defaultUnavailable) { + return; + } + props.onTrunkSelectionChange(selection); + }; + return (
{/* Trunk Branch Selector */} - {props.branches.length > 0 && ( -
- +
+ + -
- )} + )} +
{/* Runtime Selector */}
diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 20bbb1827..2cb337e74 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1016,8 +1016,10 @@ export const ChatInput: React.FC = (props) => { {variant === "creation" && ( void; + setTrunkSelection: (selection: TrunkSelection) => void; runtimeMode: RuntimeMode; sshHost: string; setRuntimeOptions: (mode: RuntimeMode, host: string) => void; @@ -62,7 +66,7 @@ export function useCreationWorkspace({ const [isSending, setIsSending] = useState(false); // Centralized draft workspace settings with automatic persistence - const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } = + const { settings, setRuntimeOptions, setTrunkBranch, setTrunkSelection, getRuntimeString } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); // Get send options from shared hook (uses project-scoped storage key) @@ -145,7 +149,10 @@ export function useCreationWorkspace({ return { branches, trunkBranch: settings.trunkBranch, + trunkSelection: settings.trunkSelection, + customTrunkBranch: settings.customTrunkBranch, setTrunkBranch, + setTrunkSelection, runtimeMode: settings.runtimeMode, sshHost: settings.sshHost, setRuntimeOptions, diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 8908a7df4..77d528371 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -13,8 +13,10 @@ import { getModelKey, getRuntimeKey, getTrunkBranchKey, + getTrunkSelectionKey, getProjectScopeId, } from "@/common/constants/storage"; +import { DEFAULT_TRUNK_BRANCH, TRUNK_SELECTION, type TrunkSelection } from "@/common/constants/workspace"; import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -33,6 +35,8 @@ export interface DraftWorkspaceSettings { runtimeMode: RuntimeMode; sshHost: string; trunkBranch: string; + trunkSelection: TrunkSelection; + customTrunkBranch: string; } /** @@ -52,6 +56,7 @@ export function useDraftWorkspaceSettings( settings: DraftWorkspaceSettings; setRuntimeOptions: (mode: RuntimeMode, host: string) => void; setTrunkBranch: (branch: string) => void; + setTrunkSelection: (selection: TrunkSelection) => void; getRuntimeString: () => string | undefined; } { // Global AI settings (read-only from global state) @@ -75,22 +80,50 @@ export function useDraftWorkspaceSettings( ); // Project-scoped trunk branch preference (persisted per project) - const [trunkBranch, setTrunkBranch] = usePersistedState( + const [customTrunkBranch, setCustomTrunkBranch] = usePersistedState( getTrunkBranchKey(projectPath), "", { listener: true } ); + const [trunkSelection, setTrunkSelection] = usePersistedState( + getTrunkSelectionKey(projectPath), + TRUNK_SELECTION.DEFAULT, + { listener: true } + ); + // Parse runtime string into mode and host const { mode: runtimeMode, host: sshHost } = parseRuntimeModeAndHost(runtimeString); - // Initialize trunk branch from backend recommendation or first branch + // Initialize custom trunk branch from backend recommendation or first branch useEffect(() => { - if (!trunkBranch && branches.length > 0) { - const defaultBranch = recommendedTrunk ?? branches[0]; - setTrunkBranch(defaultBranch); + if (branches.length === 0) { + return; } - }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); + + const recommendedInList = recommendedTrunk && branches.includes(recommendedTrunk); + const currentIsValid = customTrunkBranch && branches.includes(customTrunkBranch); + + if (currentIsValid) { + return; + } + + const fallback = (recommendedInList ? recommendedTrunk : undefined) ?? branches[0]; + setCustomTrunkBranch(fallback); + }, [branches, recommendedTrunk, customTrunkBranch, setCustomTrunkBranch]); + + // Fall back to custom mode when default "main" is unavailable in the repo + useEffect(() => { + if ( + branches.length === 0 || + trunkSelection === TRUNK_SELECTION.CUSTOM || + branches.includes(DEFAULT_TRUNK_BRANCH) + ) { + return; + } + + setTrunkSelection(TRUNK_SELECTION.CUSTOM); + }, [branches, trunkSelection, setTrunkSelection]); // Setter for runtime options (updates persisted runtime string) const setRuntimeOptions = (newMode: RuntimeMode, newHost: string) => { @@ -103,6 +136,17 @@ export function useDraftWorkspaceSettings( return buildRuntimeString(runtimeMode, sshHost); }; + const resolvedCustomBranch = + customTrunkBranch || + (recommendedTrunk ?? branches[0]) || + DEFAULT_TRUNK_BRANCH; + + const defaultAvailable = branches.length === 0 || branches.includes(DEFAULT_TRUNK_BRANCH); + const effectiveTrunkBranch = + trunkSelection === TRUNK_SELECTION.DEFAULT && defaultAvailable + ? DEFAULT_TRUNK_BRANCH + : resolvedCustomBranch; + return { settings: { model, @@ -111,10 +155,13 @@ export function useDraftWorkspaceSettings( use1M, runtimeMode, sshHost, - trunkBranch, + trunkBranch: effectiveTrunkBranch, + trunkSelection, + customTrunkBranch: resolvedCustomBranch, }, setRuntimeOptions, - setTrunkBranch, + setTrunkBranch: setCustomTrunkBranch, + setTrunkSelection, getRuntimeString, }; } diff --git a/src/browser/hooks/useStartWorkspaceCreation.test.ts b/src/browser/hooks/useStartWorkspaceCreation.test.ts index d7bab6c67..41af1cb5d 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.test.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.test.ts @@ -12,8 +12,10 @@ import { getProjectScopeId, getRuntimeKey, getTrunkBranchKey, + getTrunkSelectionKey, } from "@/common/constants/storage"; import type { ProjectConfig } from "@/node/config"; +import { TRUNK_SELECTION } from "@/common/constants/workspace"; import type { updatePersistedState } from "@/browser/hooks/usePersistedState"; @@ -71,6 +73,7 @@ describe("persistWorkspaceCreationPrefill", () => { expect(callMap.get(getInputKey(getPendingScopeId(projectPath)))).toBe("Ship it"); expect(callMap.get(getModelKey(getProjectScopeId(projectPath)))).toBe("provider/model"); expect(callMap.get(getTrunkBranchKey(projectPath))).toBe("main"); + expect(callMap.get(getTrunkSelectionKey(projectPath))).toBe(TRUNK_SELECTION.CUSTOM); expect(callMap.get(getRuntimeKey(projectPath))).toBe("ssh dev"); }); @@ -90,6 +93,7 @@ describe("persistWorkspaceCreationPrefill", () => { } expect(callMap.get(getTrunkBranchKey(projectPath))).toBeUndefined(); + expect(callMap.get(getTrunkSelectionKey(projectPath))).toBe(TRUNK_SELECTION.DEFAULT); expect(callMap.get(getRuntimeKey(projectPath))).toBeUndefined(); }); diff --git a/src/browser/hooks/useStartWorkspaceCreation.ts b/src/browser/hooks/useStartWorkspaceCreation.ts index 10d55de5d..3cf9a744d 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.ts @@ -10,8 +10,10 @@ import { getProjectScopeId, getRuntimeKey, getTrunkBranchKey, + getTrunkSelectionKey, } from "@/common/constants/storage"; import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime"; +import { TRUNK_SELECTION } from "@/common/constants/workspace"; export type StartWorkspaceCreationDetail = CustomEventPayloads[typeof CUSTOM_EVENTS.START_WORKSPACE_CREATION]; @@ -69,9 +71,11 @@ export function persistWorkspaceCreationPrefill( if (detail.trunkBranch !== undefined) { const normalizedTrunk = detail.trunkBranch.trim(); + const hasCustomTrunk = normalizedTrunk.length > 0; + persist(getTrunkBranchKey(projectPath), hasCustomTrunk ? normalizedTrunk : undefined); persist( - getTrunkBranchKey(projectPath), - normalizedTrunk.length > 0 ? normalizedTrunk : undefined + getTrunkSelectionKey(projectPath), + hasCustomTrunk ? TRUNK_SELECTION.CUSTOM : TRUNK_SELECTION.DEFAULT ); } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 5a2b2f121..6bded53ba 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -108,6 +108,14 @@ export function getTrunkBranchKey(projectPath: string): string { return `trunkBranch:${projectPath}`; } +/** + * Get the localStorage key for trunk branch selection mode (default vs custom) + * Format: "trunkSelection:{projectPath}" + */ +export function getTrunkSelectionKey(projectPath: string): string { + return `trunkSelection:${projectPath}`; +} + /** * Get the localStorage key for the 1M context preference (global) * Format: "use1MContext" diff --git a/src/common/constants/workspace.ts b/src/common/constants/workspace.ts index 04f6e3e34..f3aaa5d19 100644 --- a/src/common/constants/workspace.ts +++ b/src/common/constants/workspace.ts @@ -8,3 +8,15 @@ export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { type: "local", srcBaseDir: "~/.mux/src", } as const; + +/** + * Default trunk branch to fork from when creating workspaces + */ +export const DEFAULT_TRUNK_BRANCH = "main" as const; + +export const TRUNK_SELECTION = { + DEFAULT: "default", + CUSTOM: "custom", +} as const; + +export type TrunkSelection = (typeof TRUNK_SELECTION)[keyof typeof TRUNK_SELECTION];