authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-14 23:20:45-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-14 23:35:48-08:00
log9bd663be7fc67ebde62ea0268b53b2e9e48ef3a1
treefb29ceba08bf523ee751098ffdaec3ab994649d3
parent58d926fcfba701ebf86f596fc8f1023087493419

Add inline branch creation to branch picker search

- let users create a new local branch directly from the branch combobox search - remove separate "New branch" form/button flow in `BranchToolbar` - add `inputClassName` support to `ComboboxInput` for native input styling control

2 files changed, 51 insertions(+), 73 deletions(-)

apps/web/src/components/BranchToolbar.tsx+42-72
......@@ -19,8 +19,7 @@ import {
1919 ComboboxPopup,
2020 ComboboxTrigger,
2121} from "./ui/combobox";
22import { ChevronDownIcon, PlusIcon } from "lucide-react";
23import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group";
22import { ChevronDownIcon } from "lucide-react";
2423
2524interface BranchToolbarProps {
2625 envMode: "local" | "worktree";
......@@ -34,8 +33,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
3433 const queryClient = useQueryClient();
3534
3635 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
37 const [isCreatingBranch, setIsCreatingBranch] = useState(false);
38 const [newBranchName, setNewBranchName] = useState("");
36 const [branchQuery, setBranchQuery] = useState("");
3937
4038 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
4139 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
......@@ -51,6 +49,16 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
5149 const branches = branchesQuery.data?.branches ?? [];
5250 const branchNames = branches.map((branch) => branch.name);
5351 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
52 const trimmedBranchQuery = branchQuery.trim();
53 const canCreateBranch = envMode === "local" && trimmedBranchQuery.length > 0;
54 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
55 const createBranchItemValue = canCreateBranch
56 ? `__create_new_branch__:${trimmedBranchQuery}`
57 : null;
58 const branchPickerItems =
59 createBranchItemValue && !hasExactBranchMatch
60 ? [...branchNames, createBranchItemValue]
61 : branchNames;
5462 // ── Mutations ─────────────────────────────────────────────────────────
5563
5664 const checkoutMutation = useMutation({
......@@ -86,8 +94,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
8694
8795 useEffect(() => {
8896 if (isBranchMenuOpen) return;
89 setIsCreatingBranch(false);
90 setNewBranchName("");
97 setBranchQuery("");
9198 }, [isBranchMenuOpen]);
9299
93100 // ── Helpers ───────────────────────────────────────────────────────────
......@@ -140,15 +147,14 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
140147 });
141148 };
142149
143 const createBranch = () => {
144 const name = newBranchName.trim();
145 if (!api || !activeThreadId || !branchCwd || !name) return;
150 const createBranch = (rawName: string) => {
151 const name = rawName.trim();
152 if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return;
146153 createBranchMutation.mutate(name, {
147154 onSuccess: () => {
148155 setThreadError(null);
149156 setThreadBranch(name, activeWorktreePath);
150 setNewBranchName("");
151 setIsCreatingBranch(false);
157 setBranchQuery("");
152158 setIsBranchMenuOpen(false);
153159 },
154160 });
......@@ -177,7 +183,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
177183 </div>
178184
179185 <Combobox
180 items={branchNames}
186 items={branchPickerItems}
181187 autoHighlight
182188 onOpenChange={(open) => setIsBranchMenuOpen(open)}
183189 open={isBranchMenuOpen}
......@@ -198,20 +204,35 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
198204 <ChevronDownIcon />
199205 </ComboboxTrigger>
200206 <ComboboxPopup align="end" side="top" className="w-64">
201 <div className="border-b">
207 <div className="border-b p-1">
202208 <ComboboxInput
203 className="rounded-b-none before:rounded-b-none [&_input]:font-sans"
209 className="[&_input]:font-sans rounded-md"
210 inputClassName="ring-0"
204211 placeholder="Search branches..."
205 showClear
206212 showTrigger={false}
207213 size="sm"
214 value={branchQuery}
215 onChange={(event) => setBranchQuery(event.target.value)}
208216 />
209217 </div>
210218 <ComboboxEmpty>No branches found.</ComboboxEmpty>
211219
212220 <ComboboxList className="max-h-56">
213 {(branchName) => {
214 const branch = branchByName.get(branchName);
221 {(itemValue) => {
222 if (createBranchItemValue && itemValue === createBranchItemValue) {
223 return (
224 <ComboboxItem
225 hideIndicator
226 key={itemValue}
227 value={itemValue}
228 onClick={() => createBranch(trimmedBranchQuery)}
229 >
230 <span className="truncate">Create new branch "{trimmedBranchQuery}"</span>
231 </ComboboxItem>
232 );
233 }
234
235 const branch = branchByName.get(itemValue);
215236 if (!branch) return null;
216237
217238 const hasSecondaryWorktree =
......@@ -219,15 +240,15 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
219240 return (
220241 <ComboboxItem
221242 hideIndicator
222 key={branchName}
223 value={branchName}
243 key={itemValue}
244 value={itemValue}
224245 className={
225 branchName === activeThread.branch ? "bg-accent text-foreground" : undefined
246 itemValue === activeThread.branch ? "bg-accent text-foreground" : undefined
226247 }
227248 onClick={() => selectBranch(branch)}
228249 >
229250 <div className="flex w-full items-center justify-between gap-2">
230 <span className="truncate">{branchName}</span>
251 <span className="truncate">{itemValue}</span>
231252 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
232253 <span className="shrink-0 text-[10px] text-muted-foreground/45">
233254 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}
......@@ -238,57 +259,6 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
238259 );
239260 }}
240261 </ComboboxList>
241 {envMode === "local" && (
242 <>
243 {isCreatingBranch ? (
244 <form
245 onSubmit={(event) => {
246 event.preventDefault();
247 createBranch();
248 }}
249 >
250 <InputGroup className="rounded-t-none before:rounded-t-none">
251 <InputGroupInput
252 className="[&_input]:font-sans"
253 size="sm"
254 placeholder="branch-name"
255 type="text"
256 value={newBranchName}
257 onChange={(event) => setNewBranchName(event.target.value)}
258 onKeyDown={(event) => {
259 if (event.key === "Escape") {
260 event.stopPropagation();
261 setIsCreatingBranch(false);
262 setNewBranchName("");
263 }
264 }}
265 autoFocus
266 />
267 <InputGroupAddon align="inline-end">
268 <Button
269 size="xs"
270 variant="secondary"
271 type="submit"
272 disabled={!newBranchName.trim() || createBranchMutation.isPending}
273 >
274 Create
275 </Button>
276 </InputGroupAddon>
277 </InputGroup>
278 </form>
279 ) : (
280 <Button
281 size="sm"
282 className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none"
283 variant="outline"
284 onClick={() => setIsCreatingBranch(true)}
285 >
286 <PlusIcon />
287 New branch
288 </Button>
289 )}
290 </>
291 )}
292262 </ComboboxPopup>
293263 </Combobox>
294264 </div>
apps/web/src/components/ui/combobox.tsx+9-1
......@@ -54,12 +54,14 @@ function ComboboxChipsInput({
5454
5555function ComboboxInput({
5656 className,
57 inputClassName,
5758 showTrigger = true,
5859 showClear = false,
5960 startAddon,
6061 size,
6162 ...props
6263}: Omit<ComboboxPrimitive.Input.Props, "size"> & {
64 inputClassName?: string;
6365 showTrigger?: boolean;
6466 showClear?: boolean;
6567 startAddon?: React.ReactNode;
......@@ -89,7 +91,13 @@ function ComboboxInput({
8991 className,
9092 )}
9193 data-slot="combobox-input"
92 render={<Input className="has-disabled:opacity-100" nativeInput size={sizeValue} />}
94 render={
95 <Input
96 className={cn("has-disabled:opacity-100", inputClassName)}
97 nativeInput
98 size={sizeValue}
99 />
100 }
93101 {...props}
94102 />
95103 {showTrigger && (