Skip to content

Commit f40807f

Browse files
Guilhem-lmclaudecentdix
authored
feat: add create sub-worktree shortcut to worktree menu (#269)
* feat: add create sub-worktree shortcut to worktree menu Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address review nits on sub-worktree shortcut Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: use existing auto-naming for sub-worktrees Drop the curated NICE_WORDS list and suggestSubworktreeBranchName helper that pre-filled `<parent>-<word>` branch names. Leaving the branch field empty lets the backend's existing auto-naming take over (prompt-based when autoName is configured, `change-<uuid>` fallback otherwise), which also restores prompt-based naming that pre-filling defeated. Nesting is driven by baseBranch, not the name prefix, so the nested display is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: gitignore .claude/settings.local.json Local Claude Code settings shouldn't be committed; ignoring prevents accidentally staging it again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: centdix <farhadg110@gmail.com>
1 parent 7b460cb commit f40807f

6 files changed

Lines changed: 80 additions & 8 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ public/
1616
.env.local
1717
.webmux.local.yaml
1818
docs/useful-commands.txt
19+
.claude/settings.local.json
1920
.claude/worktrees/

frontend/src/App.svelte

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
let baseBranches = $state<AvailableBranch[]>([]);
141141
let baseBranchesLoading = $state(false);
142142
let baseBranchesError = $state<string | null>(null);
143+
let lockedBaseBranch = $state<string | null>(null);
143144
let includeRemoteBranches = $state(false);
144145
let searchQuery = $state("");
145146
let worktreeSearchInput = $state<HTMLInputElement | null>(null);
@@ -613,6 +614,14 @@
613614
function openCreateDialog(issue: LinearIssue | null = null): void {
614615
includeRemoteBranches = false;
615616
assignIssue = issue;
617+
lockedBaseBranch = null;
618+
showCreateDialog = true;
619+
}
620+
621+
function openSubworktreeDialog(parentBranch: string): void {
622+
includeRemoteBranches = false;
623+
assignIssue = null;
624+
lockedBaseBranch = parentBranch;
616625
showCreateDialog = true;
617626
}
618627
@@ -644,6 +653,7 @@
644653
: request;
645654
showCreateDialog = false;
646655
assignIssue = null;
656+
lockedBaseBranch = null;
647657
648658
try {
649659
const createPromise = api.createWorktree({ body: finalRequest });
@@ -1218,6 +1228,7 @@
12181228
mergeBranch = branch;
12191229
}}
12201230
onremove={(b) => (removeBranch = b)}
1231+
oncreatesubworktree={openSubworktreeDialog}
12211232
onposttolinear={handlePostToLinear}
12221233
/>
12231234
{#if config.projectDir}
@@ -1415,11 +1426,12 @@
14151426
{baseBranches}
14161427
{baseBranchesLoading}
14171428
{baseBranchesError}
1429+
{lockedBaseBranch}
14181430
startupEnvs={config.startupEnvs ?? {}}
14191431
linearCreateTicketOption={config.linearCreateTicketOption}
14201432
openedFromLinearIssue={assignIssue !== null}
14211433
oncreate={handleCreate}
1422-
oncancel={() => { showCreateDialog = false; assignIssue = null; }}
1434+
oncancel={() => { showCreateDialog = false; assignIssue = null; lockedBaseBranch = null; }}
14231435
/>
14241436
{/if}
14251437

frontend/src/lib/BranchSelector.svelte

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
error = null,
1212
placeholder = "Select a branch",
1313
initialOpen = false,
14+
disabled = false,
1415
inlineToggleLabel,
1516
inlineToggleAriaLabel,
1617
inlineToggleChecked = false,
@@ -24,6 +25,7 @@
2425
error?: string | null;
2526
placeholder?: string;
2627
initialOpen?: boolean;
28+
disabled?: boolean;
2729
inlineToggleLabel?: string;
2830
inlineToggleAriaLabel?: string;
2931
inlineToggleChecked?: boolean;
@@ -114,17 +116,20 @@
114116
<span class="block text-xs text-muted mb-1.5">{label}</span>
115117
<button
116118
type="button"
117-
class="flex w-full items-center justify-between gap-3 rounded-md border border-edge bg-surface px-2.5 py-1.5 text-left text-[13px] text-primary outline-none transition-colors hover:bg-hover focus:border-accent"
119+
{disabled}
120+
class="flex w-full items-center justify-between gap-3 rounded-md border border-edge bg-surface px-2.5 py-1.5 text-left text-[13px] text-primary outline-none transition-colors hover:bg-hover focus:border-accent disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-surface"
118121
aria-label={label}
119-
aria-expanded={selectorOpen}
122+
aria-expanded={disabled ? undefined : selectorOpen}
120123
onclick={toggleSelector}
121124
>
122125
<span class={selected ? "font-mono" : "text-muted/50"}>
123126
{selected || placeholder}
124127
</span>
125-
<span class="text-[11px] text-muted">{selectorOpen ? "" : ""}</span>
128+
{#if !disabled}
129+
<span class="text-[11px] text-muted">{selectorOpen ? "" : ""}</span>
130+
{/if}
126131
</button>
127-
{#if selectorOpen}
132+
{#if selectorOpen && !disabled}
128133
<div class="mt-2 rounded-lg border border-edge bg-surface/60">
129134
<div class="border-b border-edge p-2">
130135
<input

frontend/src/lib/CreateWorktreeDialog.svelte

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
baseBranches = [],
3030
baseBranchesLoading = false,
3131
baseBranchesError = null,
32+
lockedBaseBranch = null,
3233
includeRemoteBranches = $bindable(false),
3334
startupEnvs = {},
3435
linearCreateTicketOption = false,
@@ -49,6 +50,7 @@
4950
baseBranches?: AvailableBranch[];
5051
baseBranchesLoading?: boolean;
5152
baseBranchesError?: string | null;
53+
lockedBaseBranch?: string | null;
5254
includeRemoteBranches: boolean;
5355
startupEnvs?: Record<string, string | boolean>;
5456
linearCreateTicketOption?: boolean;
@@ -122,7 +124,8 @@
122124
// svelte-ignore state_referenced_locally
123125
let prompt = $state(initialPrompt);
124126
let selectedExistingBranch = $state("");
125-
let selectedBaseBranch = $state("");
127+
// svelte-ignore state_referenced_locally
128+
let selectedBaseBranch = $state(lockedBaseBranch ?? "");
126129
let multiAgentMode = $state(savedMultiAgentMode);
127130
let selectedAgentIds = $state<AgentId[]>(savedAgentIds);
128131
let profile = $state(savedProfile ?? "");
@@ -275,7 +278,7 @@
275278
});
276279
}}
277280
>
278-
<h2 class="text-base mb-4">New Worktree</h2>
281+
<h2 class="text-base mb-4">{lockedBaseBranch !== null ? "New Sub-Worktree" : "New Worktree"}</h2>
279282
<div class="mb-4">
280283
<label class="block text-xs text-muted mb-1.5" for="wt-prompt"
281284
>Prompt <span class="opacity-60">({promptRequired ? "required" : "optional"})</span></label
@@ -374,9 +377,14 @@
374377
loading={baseBranchesLoading}
375378
error={baseBranchesError}
376379
placeholder="Project main branch (default)"
380+
disabled={lockedBaseBranch !== null}
377381
onselect={(branch) => (selectedBaseBranch = branch)}
378382
/>
379-
{#if selectedBaseBranch}
383+
{#if lockedBaseBranch !== null}
384+
<p class="mt-2 text-[11px] text-muted">
385+
Creating a sub-worktree based on <span class="font-mono">{lockedBaseBranch}</span>.
386+
</p>
387+
{:else if selectedBaseBranch}
380388
<button
381389
type="button"
382390
class="mt-2 text-[11px] text-accent hover:underline"

frontend/src/lib/WorktreeList.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
onarchive,
3131
onmerge,
3232
onremove,
33+
oncreatesubworktree,
3334
onposttolinear,
3435
}: {
3536
rows: WorktreeListRow[];
@@ -45,6 +46,7 @@
4546
onarchive: (branch: string) => void;
4647
onmerge: (branch: string) => void;
4748
onremove: (branch: string) => void;
49+
oncreatesubworktree: (branch: string) => void;
4850
onposttolinear?: (branch: string) => void;
4951
} = $props();
5052
@@ -368,6 +370,17 @@
368370
>
369371
Merge
370372
</button>
373+
<button
374+
type="button"
375+
disabled={isCreating}
376+
class="w-full px-2 py-1.5 rounded text-left text-xs text-primary hover:bg-hover disabled:opacity-50 disabled:cursor-not-allowed"
377+
onclick={(event) => {
378+
event.stopPropagation();
379+
runMenuAction(wt.branch, oncreatesubworktree);
380+
}}
381+
>
382+
Create sub-worktree
383+
</button>
371384
<button
372385
type="button"
373386
class="w-full px-2 py-1.5 rounded text-left text-xs text-danger hover:bg-hover"

frontend/src/lib/WorktreeList.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ describe("WorktreeList", () => {
6363
onclose: vi.fn(),
6464
onarchive: vi.fn(),
6565
onmerge: vi.fn(),
66+
oncreatesubworktree: vi.fn(),
6667
onremove,
6768
},
6869
});
@@ -88,6 +89,7 @@ describe("WorktreeList", () => {
8889
onclose: vi.fn(),
8990
onarchive: vi.fn(),
9091
onmerge: vi.fn(),
92+
oncreatesubworktree: vi.fn(),
9193
onremove: vi.fn(),
9294
},
9395
});
@@ -112,6 +114,7 @@ describe("WorktreeList", () => {
112114
onclose: vi.fn(),
113115
onarchive,
114116
onmerge: vi.fn(),
117+
oncreatesubworktree: vi.fn(),
115118
onremove: vi.fn(),
116119
},
117120
});
@@ -127,6 +130,33 @@ describe("WorktreeList", () => {
127130
expect(onarchive).toHaveBeenCalledWith("feature/menu-actions");
128131
});
129132

133+
it("calls oncreatesubworktree with the row branch from the menu", async () => {
134+
const oncreatesubworktree = vi.fn();
135+
136+
render(WorktreeList, {
137+
props: {
138+
rows: [createRow(createWorktree("feature/sub-base"))],
139+
selected: null,
140+
removing: new Set<string>(),
141+
initializing: new Set<string>(),
142+
archiving: new Set<string>(),
143+
postingLinear: new Set<string>(),
144+
notifiedBranches: new Set<string>(),
145+
onselect: vi.fn(),
146+
onclose: vi.fn(),
147+
onarchive: vi.fn(),
148+
onmerge: vi.fn(),
149+
oncreatesubworktree,
150+
onremove: vi.fn(),
151+
},
152+
});
153+
154+
await fireEvent.click(screen.getByRole("button", { name: /actions for feature\/sub-base/i }));
155+
await fireEvent.click(screen.getByRole("button", { name: "Create sub-worktree" }));
156+
157+
expect(oncreatesubworktree).toHaveBeenCalledWith("feature/sub-base");
158+
});
159+
130160
it("renders labels as the primary row name with the branch below", () => {
131161
render(WorktreeList, {
132162
props: {
@@ -141,6 +171,7 @@ describe("WorktreeList", () => {
141171
onclose: vi.fn(),
142172
onarchive: vi.fn(),
143173
onmerge: vi.fn(),
174+
oncreatesubworktree: vi.fn(),
144175
onremove: vi.fn(),
145176
},
146177
});
@@ -169,6 +200,7 @@ describe("WorktreeList", () => {
169200
onclose: vi.fn(),
170201
onarchive: vi.fn(),
171202
onmerge: vi.fn(),
203+
oncreatesubworktree: vi.fn(),
172204
onremove: vi.fn(),
173205
},
174206
});
@@ -202,6 +234,7 @@ describe("WorktreeList", () => {
202234
onclose: vi.fn(),
203235
onarchive: vi.fn(),
204236
onmerge: vi.fn(),
237+
oncreatesubworktree: vi.fn(),
205238
onremove: vi.fn(),
206239
},
207240
});

0 commit comments

Comments
 (0)