Skip to content

Commit dda0d30

Browse files
committed
🤖 feat: add default vs custom trunk branch toggle
Generated with
1 parent 7d2f8cc commit dda0d30

File tree

8 files changed

+140
-25
lines changed

8 files changed

+140
-25
lines changed

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import React from "react";
22
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
33
import { TooltipWrapper, Tooltip } from "../Tooltip";
44
import { Select } from "../Select";
5+
import { DEFAULT_TRUNK_BRANCH, TRUNK_SELECTION, type TrunkSelection } from "@/common/constants/workspace";
56

67
interface CreationControlsProps {
78
branches: string[];
8-
trunkBranch: string;
9-
onTrunkBranchChange: (branch: string) => void;
9+
trunkSelection: TrunkSelection;
10+
customTrunkBranch: string;
11+
onTrunkSelectionChange: (selection: TrunkSelection) => void;
12+
onCustomTrunkBranchChange: (branch: string) => void;
1013
runtimeMode: RuntimeMode;
1114
sshHost: string;
1215
onRuntimeChange: (mode: RuntimeMode, host: string) => void;
@@ -19,24 +22,52 @@ interface CreationControlsProps {
1922
* - Runtime mode (local vs SSH)
2023
*/
2124
export function CreationControls(props: CreationControlsProps) {
25+
const defaultUnavailable =
26+
props.branches.length > 0 && !props.branches.includes(DEFAULT_TRUNK_BRANCH);
27+
const showCustomPicker = props.trunkSelection === TRUNK_SELECTION.CUSTOM;
28+
29+
const handleTrunkSelectionChange = (value: string) => {
30+
const selection = value as TrunkSelection;
31+
if (selection === TRUNK_SELECTION.DEFAULT && defaultUnavailable) {
32+
return;
33+
}
34+
props.onTrunkSelectionChange(selection);
35+
};
36+
2237
return (
2338
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
2439
{/* Trunk Branch Selector */}
25-
{props.branches.length > 0 && (
26-
<div className="flex items-center gap-1" data-component="TrunkBranchGroup">
27-
<label htmlFor="trunk-branch" className="text-muted text-xs">
28-
From:
29-
</label>
40+
<div className="flex items-center gap-1" data-component="TrunkBranchGroup">
41+
<label htmlFor="trunk-branch-mode" className="text-muted text-xs">
42+
From:
43+
</label>
44+
<Select
45+
id="trunk-branch-mode"
46+
value={props.trunkSelection}
47+
options={[
48+
{ value: TRUNK_SELECTION.DEFAULT, label: "Main" },
49+
{ value: TRUNK_SELECTION.CUSTOM, label: "Custom" },
50+
]}
51+
onChange={handleTrunkSelectionChange}
52+
disabled={props.disabled}
53+
className="max-w-[110px]"
54+
aria-label="Trunk branch selection"
55+
/>
56+
{defaultUnavailable && (
57+
<span className="text-muted text-[11px]">Main branch not found</span>
58+
)}
59+
{showCustomPicker && props.branches.length > 0 && (
3060
<Select
3161
id="trunk-branch"
32-
value={props.trunkBranch}
62+
value={props.customTrunkBranch}
3363
options={props.branches}
34-
onChange={props.onTrunkBranchChange}
64+
onChange={props.onCustomTrunkBranchChange}
3565
disabled={props.disabled}
36-
className="max-w-[120px]"
66+
className="max-w-[130px]"
67+
aria-label="Custom trunk branch"
3768
/>
38-
</div>
39-
)}
69+
)}
70+
</div>
4071

4172
{/* Runtime Selector */}
4273
<div className="flex items-center gap-1" data-component="RuntimeSelectorGroup">

src/browser/components/ChatInput/index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,8 +1016,10 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10161016
{variant === "creation" && (
10171017
<CreationControls
10181018
branches={creationState.branches}
1019-
trunkBranch={creationState.trunkBranch}
1020-
onTrunkBranchChange={creationState.setTrunkBranch}
1019+
trunkSelection={creationState.trunkSelection}
1020+
customTrunkBranch={creationState.customTrunkBranch}
1021+
onTrunkSelectionChange={creationState.setTrunkSelection}
1022+
onCustomTrunkBranchChange={creationState.setTrunkBranch}
10211023
runtimeMode={creationState.runtimeMode}
10221024
sshHost={creationState.sshHost}
10231025
onRuntimeChange={creationState.setRuntimeOptions}

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { UIMode } from "@/common/types/mode";
55
import type { ThinkingLevel } from "@/common/types/thinking";
66
import { parseRuntimeString } from "@/browser/utils/chatCommands";
77
import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
8+
import type { TrunkSelection } from "@/common/constants/workspace";
89
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
910
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
1011
import { getModeKey, getProjectScopeId, getThinkingLevelKey } from "@/common/constants/storage";
@@ -35,7 +36,10 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
3536
interface UseCreationWorkspaceReturn {
3637
branches: string[];
3738
trunkBranch: string;
39+
trunkSelection: TrunkSelection;
40+
customTrunkBranch: string;
3841
setTrunkBranch: (branch: string) => void;
42+
setTrunkSelection: (selection: TrunkSelection) => void;
3943
runtimeMode: RuntimeMode;
4044
sshHost: string;
4145
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
@@ -62,7 +66,7 @@ export function useCreationWorkspace({
6266
const [isSending, setIsSending] = useState(false);
6367

6468
// Centralized draft workspace settings with automatic persistence
65-
const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } =
69+
const { settings, setRuntimeOptions, setTrunkBranch, setTrunkSelection, getRuntimeString } =
6670
useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk);
6771

6872
// Get send options from shared hook (uses project-scoped storage key)
@@ -145,7 +149,10 @@ export function useCreationWorkspace({
145149
return {
146150
branches,
147151
trunkBranch: settings.trunkBranch,
152+
trunkSelection: settings.trunkSelection,
153+
customTrunkBranch: settings.customTrunkBranch,
148154
setTrunkBranch,
155+
setTrunkSelection,
149156
runtimeMode: settings.runtimeMode,
150157
sshHost: settings.sshHost,
151158
setRuntimeOptions,

src/browser/hooks/useDraftWorkspaceSettings.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import {
1313
getModelKey,
1414
getRuntimeKey,
1515
getTrunkBranchKey,
16+
getTrunkSelectionKey,
1617
getProjectScopeId,
1718
} from "@/common/constants/storage";
19+
import { DEFAULT_TRUNK_BRANCH, TRUNK_SELECTION, type TrunkSelection } from "@/common/constants/workspace";
1820
import type { UIMode } from "@/common/types/mode";
1921
import type { ThinkingLevel } from "@/common/types/thinking";
2022

@@ -33,6 +35,8 @@ export interface DraftWorkspaceSettings {
3335
runtimeMode: RuntimeMode;
3436
sshHost: string;
3537
trunkBranch: string;
38+
trunkSelection: TrunkSelection;
39+
customTrunkBranch: string;
3640
}
3741

3842
/**
@@ -52,6 +56,7 @@ export function useDraftWorkspaceSettings(
5256
settings: DraftWorkspaceSettings;
5357
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
5458
setTrunkBranch: (branch: string) => void;
59+
setTrunkSelection: (selection: TrunkSelection) => void;
5560
getRuntimeString: () => string | undefined;
5661
} {
5762
// Global AI settings (read-only from global state)
@@ -75,22 +80,50 @@ export function useDraftWorkspaceSettings(
7580
);
7681

7782
// Project-scoped trunk branch preference (persisted per project)
78-
const [trunkBranch, setTrunkBranch] = usePersistedState<string>(
83+
const [customTrunkBranch, setCustomTrunkBranch] = usePersistedState<string>(
7984
getTrunkBranchKey(projectPath),
8085
"",
8186
{ listener: true }
8287
);
8388

89+
const [trunkSelection, setTrunkSelection] = usePersistedState<TrunkSelection>(
90+
getTrunkSelectionKey(projectPath),
91+
TRUNK_SELECTION.DEFAULT,
92+
{ listener: true }
93+
);
94+
8495
// Parse runtime string into mode and host
8596
const { mode: runtimeMode, host: sshHost } = parseRuntimeModeAndHost(runtimeString);
8697

87-
// Initialize trunk branch from backend recommendation or first branch
98+
// Initialize custom trunk branch from backend recommendation or first branch
8899
useEffect(() => {
89-
if (!trunkBranch && branches.length > 0) {
90-
const defaultBranch = recommendedTrunk ?? branches[0];
91-
setTrunkBranch(defaultBranch);
100+
if (branches.length === 0) {
101+
return;
92102
}
93-
}, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]);
103+
104+
const recommendedInList = recommendedTrunk && branches.includes(recommendedTrunk);
105+
const currentIsValid = customTrunkBranch && branches.includes(customTrunkBranch);
106+
107+
if (currentIsValid) {
108+
return;
109+
}
110+
111+
const fallback = (recommendedInList ? recommendedTrunk : undefined) ?? branches[0];
112+
setCustomTrunkBranch(fallback);
113+
}, [branches, recommendedTrunk, customTrunkBranch, setCustomTrunkBranch]);
114+
115+
// Fall back to custom mode when default "main" is unavailable in the repo
116+
useEffect(() => {
117+
if (
118+
branches.length === 0 ||
119+
trunkSelection === TRUNK_SELECTION.CUSTOM ||
120+
branches.includes(DEFAULT_TRUNK_BRANCH)
121+
) {
122+
return;
123+
}
124+
125+
setTrunkSelection(TRUNK_SELECTION.CUSTOM);
126+
}, [branches, trunkSelection, setTrunkSelection]);
94127

95128
// Setter for runtime options (updates persisted runtime string)
96129
const setRuntimeOptions = (newMode: RuntimeMode, newHost: string) => {
@@ -103,6 +136,17 @@ export function useDraftWorkspaceSettings(
103136
return buildRuntimeString(runtimeMode, sshHost);
104137
};
105138

139+
const resolvedCustomBranch =
140+
customTrunkBranch ||
141+
(recommendedTrunk ?? branches[0]) ||
142+
DEFAULT_TRUNK_BRANCH;
143+
144+
const defaultAvailable = branches.length === 0 || branches.includes(DEFAULT_TRUNK_BRANCH);
145+
const effectiveTrunkBranch =
146+
trunkSelection === TRUNK_SELECTION.DEFAULT && defaultAvailable
147+
? DEFAULT_TRUNK_BRANCH
148+
: resolvedCustomBranch;
149+
106150
return {
107151
settings: {
108152
model,
@@ -111,10 +155,13 @@ export function useDraftWorkspaceSettings(
111155
use1M,
112156
runtimeMode,
113157
sshHost,
114-
trunkBranch,
158+
trunkBranch: effectiveTrunkBranch,
159+
trunkSelection,
160+
customTrunkBranch: resolvedCustomBranch,
115161
},
116162
setRuntimeOptions,
117-
setTrunkBranch,
163+
setTrunkBranch: setCustomTrunkBranch,
164+
setTrunkSelection,
118165
getRuntimeString,
119166
};
120167
}

src/browser/hooks/useStartWorkspaceCreation.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import {
1212
getProjectScopeId,
1313
getRuntimeKey,
1414
getTrunkBranchKey,
15+
getTrunkSelectionKey,
1516
} from "@/common/constants/storage";
1617
import type { ProjectConfig } from "@/node/config";
18+
import { TRUNK_SELECTION } from "@/common/constants/workspace";
1719

1820
import type { updatePersistedState } from "@/browser/hooks/usePersistedState";
1921

@@ -71,6 +73,7 @@ describe("persistWorkspaceCreationPrefill", () => {
7173
expect(callMap.get(getInputKey(getPendingScopeId(projectPath)))).toBe("Ship it");
7274
expect(callMap.get(getModelKey(getProjectScopeId(projectPath)))).toBe("provider/model");
7375
expect(callMap.get(getTrunkBranchKey(projectPath))).toBe("main");
76+
expect(callMap.get(getTrunkSelectionKey(projectPath))).toBe(TRUNK_SELECTION.CUSTOM);
7477
expect(callMap.get(getRuntimeKey(projectPath))).toBe("ssh dev");
7578
});
7679

@@ -90,6 +93,7 @@ describe("persistWorkspaceCreationPrefill", () => {
9093
}
9194

9295
expect(callMap.get(getTrunkBranchKey(projectPath))).toBeUndefined();
96+
expect(callMap.get(getTrunkSelectionKey(projectPath))).toBe(TRUNK_SELECTION.DEFAULT);
9397
expect(callMap.get(getRuntimeKey(projectPath))).toBeUndefined();
9498
});
9599

src/browser/hooks/useStartWorkspaceCreation.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import {
1010
getProjectScopeId,
1111
getRuntimeKey,
1212
getTrunkBranchKey,
13+
getTrunkSelectionKey,
1314
} from "@/common/constants/storage";
1415
import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime";
16+
import { TRUNK_SELECTION } from "@/common/constants/workspace";
1517

1618
export type StartWorkspaceCreationDetail =
1719
CustomEventPayloads[typeof CUSTOM_EVENTS.START_WORKSPACE_CREATION];
@@ -69,9 +71,11 @@ export function persistWorkspaceCreationPrefill(
6971

7072
if (detail.trunkBranch !== undefined) {
7173
const normalizedTrunk = detail.trunkBranch.trim();
74+
const hasCustomTrunk = normalizedTrunk.length > 0;
75+
persist(getTrunkBranchKey(projectPath), hasCustomTrunk ? normalizedTrunk : undefined);
7276
persist(
73-
getTrunkBranchKey(projectPath),
74-
normalizedTrunk.length > 0 ? normalizedTrunk : undefined
77+
getTrunkSelectionKey(projectPath),
78+
hasCustomTrunk ? TRUNK_SELECTION.CUSTOM : TRUNK_SELECTION.DEFAULT
7579
);
7680
}
7781

src/common/constants/storage.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ export function getTrunkBranchKey(projectPath: string): string {
108108
return `trunkBranch:${projectPath}`;
109109
}
110110

111+
/**
112+
* Get the localStorage key for trunk branch selection mode (default vs custom)
113+
* Format: "trunkSelection:{projectPath}"
114+
*/
115+
export function getTrunkSelectionKey(projectPath: string): string {
116+
return `trunkSelection:${projectPath}`;
117+
}
118+
111119
/**
112120
* Get the localStorage key for the 1M context preference (global)
113121
* Format: "use1MContext"

src/common/constants/workspace.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,15 @@ export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
88
type: "local",
99
srcBaseDir: "~/.mux/src",
1010
} as const;
11+
12+
/**
13+
* Default trunk branch to fork from when creating workspaces
14+
*/
15+
export const DEFAULT_TRUNK_BRANCH = "main" as const;
16+
17+
export const TRUNK_SELECTION = {
18+
DEFAULT: "default",
19+
CUSTOM: "custom",
20+
} as const;
21+
22+
export type TrunkSelection = (typeof TRUNK_SELECTION)[keyof typeof TRUNK_SELECTION];

0 commit comments

Comments
 (0)