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 {...@@ -19,8 +19,7 @@ import {
19 ComboboxPopup,19 ComboboxPopup,
20 ComboboxTrigger,20 ComboboxTrigger,
21} from "./ui/combobox";21} from "./ui/combobox";
22import { ChevronDownIcon, PlusIcon } from "lucide-react";22import { ChevronDownIcon } from "lucide-react";
23import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group";
2423
25interface BranchToolbarProps {24interface BranchToolbarProps {
26 envMode: "local" | "worktree";25 envMode: "local" | "worktree";
...@@ -34,8 +33,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -34,8 +33,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
34 const queryClient = useQueryClient();33 const queryClient = useQueryClient();
3534
36 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);35 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
37 const [isCreatingBranch, setIsCreatingBranch] = useState(false);36 const [branchQuery, setBranchQuery] = useState("");
38 const [newBranchName, setNewBranchName] = useState("");
3937
40 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);38 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
41 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);39 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
...@@ -51,6 +49,16 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -51,6 +49,16 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
51 const branches = branchesQuery.data?.branches ?? [];49 const branches = branchesQuery.data?.branches ?? [];
52 const branchNames = branches.map((branch) => branch.name);50 const branchNames = branches.map((branch) => branch.name);
53 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));51 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;
54 // ── Mutations ─────────────────────────────────────────────────────────62 // ── Mutations ─────────────────────────────────────────────────────────
5563
56 const checkoutMutation = useMutation({64 const checkoutMutation = useMutation({
...@@ -86,8 +94,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -86,8 +94,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
8694
87 useEffect(() => {95 useEffect(() => {
88 if (isBranchMenuOpen) return;96 if (isBranchMenuOpen) return;
89 setIsCreatingBranch(false);97 setBranchQuery("");
90 setNewBranchName("");
91 }, [isBranchMenuOpen]);98 }, [isBranchMenuOpen]);
9299
93 // ── Helpers ───────────────────────────────────────────────────────────100 // ── Helpers ───────────────────────────────────────────────────────────
...@@ -140,15 +147,14 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -140,15 +147,14 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
140 });147 });
141 };148 };
142149
143 const createBranch = () => {150 const createBranch = (rawName: string) => {
144 const name = newBranchName.trim();151 const name = rawName.trim();
145 if (!api || !activeThreadId || !branchCwd || !name) return;152 if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return;
146 createBranchMutation.mutate(name, {153 createBranchMutation.mutate(name, {
147 onSuccess: () => {154 onSuccess: () => {
148 setThreadError(null);155 setThreadError(null);
149 setThreadBranch(name, activeWorktreePath);156 setThreadBranch(name, activeWorktreePath);
150 setNewBranchName("");157 setBranchQuery("");
151 setIsCreatingBranch(false);
152 setIsBranchMenuOpen(false);158 setIsBranchMenuOpen(false);
153 },159 },
154 });160 });
...@@ -177,7 +183,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -177,7 +183,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
177 </div>183 </div>
178184
179 <Combobox185 <Combobox
180 items={branchNames}186 items={branchPickerItems}
181 autoHighlight187 autoHighlight
182 onOpenChange={(open) => setIsBranchMenuOpen(open)}188 onOpenChange={(open) => setIsBranchMenuOpen(open)}
183 open={isBranchMenuOpen}189 open={isBranchMenuOpen}
...@@ -198,20 +204,35 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -198,20 +204,35 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
198 <ChevronDownIcon />204 <ChevronDownIcon />
199 </ComboboxTrigger>205 </ComboboxTrigger>
200 <ComboboxPopup align="end" side="top" className="w-64">206 <ComboboxPopup align="end" side="top" className="w-64">
201 <div className="border-b">207 <div className="border-b p-1">
202 <ComboboxInput208 <ComboboxInput
203 className="rounded-b-none before:rounded-b-none [&_input]:font-sans"209 className="[&_input]:font-sans rounded-md"
210 inputClassName="ring-0"
204 placeholder="Search branches..."211 placeholder="Search branches..."
205 showClear
206 showTrigger={false}212 showTrigger={false}
207 size="sm"213 size="sm"
214 value={branchQuery}
215 onChange={(event) => setBranchQuery(event.target.value)}
208 />216 />
209 </div>217 </div>
210 <ComboboxEmpty>No branches found.</ComboboxEmpty>218 <ComboboxEmpty>No branches found.</ComboboxEmpty>
211219
212 <ComboboxList className="max-h-56">220 <ComboboxList className="max-h-56">
213 {(branchName) => {221 {(itemValue) => {
214 const branch = branchByName.get(branchName);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);
215 if (!branch) return null;236 if (!branch) return null;
216237
217 const hasSecondaryWorktree =238 const hasSecondaryWorktree =
...@@ -219,15 +240,15 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -219,15 +240,15 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
219 return (240 return (
220 <ComboboxItem241 <ComboboxItem
221 hideIndicator242 hideIndicator
222 key={branchName}243 key={itemValue}
223 value={branchName}244 value={itemValue}
224 className={245 className={
225 branchName === activeThread.branch ? "bg-accent text-foreground" : undefined246 itemValue === activeThread.branch ? "bg-accent text-foreground" : undefined
226 }247 }
227 onClick={() => selectBranch(branch)}248 onClick={() => selectBranch(branch)}
228 >249 >
229 <div className="flex w-full items-center justify-between gap-2">250 <div className="flex w-full items-center justify-between gap-2">
230 <span className="truncate">{branchName}</span>251 <span className="truncate">{itemValue}</span>
231 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (252 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
232 <span className="shrink-0 text-[10px] text-muted-foreground/45">253 <span className="shrink-0 text-[10px] text-muted-foreground/45">
233 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}254 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}
...@@ -238,57 +259,6 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -238,57 +259,6 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
238 );259 );
239 }}260 }}
240 </ComboboxList>261 </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 )}
292 </ComboboxPopup>262 </ComboboxPopup>
293 </Combobox>263 </Combobox>
294 </div>264 </div>
apps/web/src/components/ui/combobox.tsx+9-1
...@@ -54,12 +54,14 @@ function ComboboxChipsInput({...@@ -54,12 +54,14 @@ function ComboboxChipsInput({
5454
55function ComboboxInput({55function ComboboxInput({
56 className,56 className,
57 inputClassName,
57 showTrigger = true,58 showTrigger = true,
58 showClear = false,59 showClear = false,
59 startAddon,60 startAddon,
60 size,61 size,
61 ...props62 ...props
62}: Omit<ComboboxPrimitive.Input.Props, "size"> & {63}: Omit<ComboboxPrimitive.Input.Props, "size"> & {
64 inputClassName?: string;
63 showTrigger?: boolean;65 showTrigger?: boolean;
64 showClear?: boolean;66 showClear?: boolean;
65 startAddon?: React.ReactNode;67 startAddon?: React.ReactNode;
...@@ -89,7 +91,13 @@ function ComboboxInput({...@@ -89,7 +91,13 @@ function ComboboxInput({
89 className,91 className,
90 )}92 )}
91 data-slot="combobox-input"93 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 }
93 {...props}101 {...props}
94 />102 />
95 {showTrigger && (103 {showTrigger && (