Skip to content

Commit 9dad295

Browse files
authored
feat(task-input): cache cloud default branch for instant "start on trunk" (#3496)
1 parent ed6cdfb commit 9dad295

5 files changed

Lines changed: 274 additions & 4 deletions

File tree

packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,133 @@ describe("BranchSelector cloud mode", () => {
7575
expect(screen.getByRole("combobox", { name: "Branch" })).toBeEnabled();
7676
});
7777

78+
it("seeds the default branch as a pickable item with a loading row while the cloud list loads", async () => {
79+
const user = userEvent.setup();
80+
renderInTheme(
81+
<BranchSelector
82+
repoPath="owner/repo"
83+
currentBranch={null}
84+
defaultBranch="main"
85+
workspaceMode="cloud"
86+
cloudBranches={[]}
87+
cloudBranchesLoading={true}
88+
cloudSearchQuery=""
89+
selectedBranch="main"
90+
onBranchSelect={vi.fn()}
91+
onCloudSearchChange={vi.fn()}
92+
/>,
93+
);
94+
95+
await user.click(screen.getByRole("combobox", { name: "Branch" }));
96+
97+
expect(await screen.findByRole("option", { name: "main" })).toBeVisible();
98+
expect(screen.getByText("Loading branches…")).toBeVisible();
99+
// The seeded row makes the list non-empty, so the empty-state stays gated
100+
// off (Base UI only reveals it when the content group is data-empty) — it
101+
// keeps the `hidden` class rather than flashing "No branches found." above
102+
// the trunk row.
103+
expect(screen.getByText("No branches found.")).toHaveClass("hidden");
104+
});
105+
106+
it("does not seed the default branch once the user is searching", async () => {
107+
const user = userEvent.setup();
108+
renderInTheme(
109+
<BranchSelector
110+
repoPath="owner/repo"
111+
currentBranch={null}
112+
defaultBranch="main"
113+
workspaceMode="cloud"
114+
cloudBranches={[]}
115+
cloudBranchesLoading={true}
116+
cloudSearchQuery="feat"
117+
onBranchSelect={vi.fn()}
118+
onCloudSearchChange={vi.fn()}
119+
/>,
120+
);
121+
122+
await user.click(screen.getByRole("combobox", { name: "Branch" }));
123+
124+
expect(screen.queryByRole("option", { name: "main" })).toBeNull();
125+
});
126+
127+
it("re-selects the default when a stale cached default is replaced by the live one", () => {
128+
const onBranchSelect = vi.fn();
129+
const { rerender } = renderInTheme(
130+
<BranchSelector
131+
repoPath="owner/repo"
132+
currentBranch={null}
133+
defaultBranch="master"
134+
workspaceMode="cloud"
135+
cloudBranches={[]}
136+
cloudBranchesLoading={true}
137+
cloudSearchQuery=""
138+
selectedBranch={null}
139+
onBranchSelect={onBranchSelect}
140+
onCloudSearchChange={vi.fn()}
141+
/>,
142+
);
143+
144+
// Auto-selected the (stale) cached default.
145+
expect(onBranchSelect).toHaveBeenLastCalledWith("master");
146+
147+
// Parent commits that selection; then the live default arrives differing.
148+
rerender(
149+
<Theme>
150+
<BranchSelector
151+
repoPath="owner/repo"
152+
currentBranch={null}
153+
defaultBranch="main"
154+
workspaceMode="cloud"
155+
cloudBranches={["main"]}
156+
cloudBranchesLoading={false}
157+
cloudSearchQuery=""
158+
selectedBranch="master"
159+
onBranchSelect={onBranchSelect}
160+
onCloudSearchChange={vi.fn()}
161+
/>
162+
</Theme>,
163+
);
164+
165+
expect(onBranchSelect).toHaveBeenLastCalledWith("main");
166+
});
167+
168+
it("does not override a branch the user picked when the default later changes", () => {
169+
const onBranchSelect = vi.fn();
170+
const { rerender } = renderInTheme(
171+
<BranchSelector
172+
repoPath="owner/repo"
173+
currentBranch={null}
174+
defaultBranch="master"
175+
workspaceMode="cloud"
176+
cloudBranches={["master", "feature-x"]}
177+
cloudBranchesLoading={false}
178+
cloudSearchQuery=""
179+
selectedBranch="feature-x"
180+
onBranchSelect={onBranchSelect}
181+
onCloudSearchChange={vi.fn()}
182+
/>,
183+
);
184+
185+
rerender(
186+
<Theme>
187+
<BranchSelector
188+
repoPath="owner/repo"
189+
currentBranch={null}
190+
defaultBranch="main"
191+
workspaceMode="cloud"
192+
cloudBranches={["main", "feature-x"]}
193+
cloudBranchesLoading={false}
194+
cloudSearchQuery=""
195+
selectedBranch="feature-x"
196+
onBranchSelect={onBranchSelect}
197+
onCloudSearchChange={vi.fn()}
198+
/>
199+
</Theme>,
200+
);
201+
202+
expect(onBranchSelect).not.toHaveBeenCalled();
203+
});
204+
78205
it("surfaces the 'Use input as branch name' action when the typed value is new", async () => {
79206
const user = userEvent.setup();
80207
renderInTheme(

packages/ui/src/features/git-interaction/components/BranchSelector.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ import { getSuggestedBranchName } from "../utils/getSuggestedBranchName";
4040

4141
const COMBOBOX_LIMIT = 50;
4242

43+
// Shared so the two "still loading branches" render sites (the empty-list
44+
// spinner and the seeded-default row) can never drift out of sync on a copy edit.
45+
const LOADING_BRANCHES_LABEL = "Loading branches…";
46+
4347
// Sentinel value for the "Create new branch" action. Rendered as a real
4448
// ComboboxItem in the list footer so it's reachable by keyboard, not a
4549
// plain button the combobox's roving focus skips over.
@@ -140,8 +144,19 @@ export function BranchSelector({
140144
const isSelectionOnly = workspaceMode === "worktree" || isCloudMode;
141145
const displayedBranch = isSelectionOnly ? selectedBranch : currentBranch;
142146

147+
// The branch we auto-selected, so we can tell our own pick apart from one the
148+
// user made. Lets us correct a stale default (e.g. a cached "trunk" that the
149+
// live list later contradicts) without ever clobbering a deliberate choice.
150+
const autoSelectedBranchRef = useRef<string | null>(null);
143151
useEffect(() => {
144-
if (isSelectionOnly && defaultBranch && !selectedBranch && onBranchSelect) {
152+
if (!isSelectionOnly || !defaultBranch || !onBranchSelect) return;
153+
// Adopt the default when nothing is selected yet, or when the default has
154+
// changed out from under a value we ourselves auto-selected — but leave a
155+
// user's own selection alone.
156+
const selectionIsOurs =
157+
!selectedBranch || selectedBranch === autoSelectedBranchRef.current;
158+
if (selectionIsOurs && selectedBranch !== defaultBranch) {
159+
autoSelectedBranchRef.current = defaultBranch;
145160
onBranchSelect(defaultBranch);
146161
}
147162
}, [isSelectionOnly, defaultBranch, selectedBranch, onBranchSelect]);
@@ -175,12 +190,28 @@ export function BranchSelector({
175190
return byBranch;
176191
}, [repoCheckouts]);
177192

178-
const branches = isCloudMode ? (cloudBranches ?? []) : localBranches;
193+
const liveBranches = isCloudMode ? (cloudBranches ?? []) : localBranches;
179194
const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading);
180195
const branchListLoading = isCloudMode
181196
? !!cloudBranchesLoading
182197
: localBranchesLoading;
183198

199+
// On a cold start the live cloud branch list is still empty while the (slow)
200+
// remote fetch runs. Surface the known default ("trunk") branch as a real
201+
// list item straight away — with a loading row rendered below it — so the
202+
// common "start on trunk" case is pickable with zero wait. Only when there's
203+
// no active search: once the user types, the results should be purely what
204+
// the remote returns.
205+
const seededDefaultBranch =
206+
isCloudMode &&
207+
branchListLoading &&
208+
liveBranches.length === 0 &&
209+
!!defaultBranch &&
210+
!(cloudSearchQuery ?? "").trim()
211+
? defaultBranch
212+
: null;
213+
const branches = seededDefaultBranch ? [seededDefaultBranch] : liveBranches;
214+
184215
const checkoutMutation = useMutation({
185216
...trpc.git.checkoutBranch.mutationOptions(),
186217
onSuccess: () => {
@@ -456,7 +487,7 @@ export function BranchSelector({
456487
) : null}
457488

458489
{branchListLoading && branches.length === 0 ? (
459-
<LoadingRow label="Loading branches…" />
490+
<LoadingRow label={LOADING_BRANCHES_LABEL} />
460491
) : (
461492
<ComboboxEmpty>No branches found.</ComboboxEmpty>
462493
)}
@@ -519,6 +550,15 @@ export function BranchSelector({
519550
}}
520551
</ComboboxList>
521552

553+
{/*
554+
Cold start: the default ("trunk") branch is seeded as the only list
555+
item while the remote list loads. A loading row directly below it
556+
makes clear the rest of the branches are still on the way.
557+
*/}
558+
{seededDefaultBranch ? (
559+
<LoadingRow label={LOADING_BRANCHES_LABEL} />
560+
) : null}
561+
522562
{isCloudMode && cloudBranchesHasMore ? (
523563
<ComboboxListFooter>
524564
<div className="px-2 pb-2">

packages/ui/src/features/settings/settingsStore.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe("feature settingsStore cloud selections", () => {
5858
allowBypassPermissions: false,
5959
lastUsedCloudRepository: null,
6060
cachedCloudRepositoryMap: {},
61+
cachedCloudDefaultBranchMap: {},
6162
});
6263
});
6364

@@ -141,6 +142,63 @@ describe("feature settingsStore cloud selections", () => {
141142
});
142143
});
143144

145+
it("caches and persists the cloud default branch per repo", async () => {
146+
useSettingsStore
147+
.getState()
148+
.setCachedCloudDefaultBranch("posthog/posthog", "master");
149+
useSettingsStore
150+
.getState()
151+
.setCachedCloudDefaultBranch("posthog/code", "main");
152+
153+
expect(useSettingsStore.getState().cachedCloudDefaultBranchMap).toEqual({
154+
"posthog/posthog": "master",
155+
"posthog/code": "main",
156+
});
157+
158+
await waitForPersistedWrite();
159+
160+
const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1];
161+
const persisted = JSON.parse(lastCall[1]);
162+
163+
expect(persisted.state.cachedCloudDefaultBranchMap).toEqual({
164+
"posthog/posthog": "master",
165+
"posthog/code": "main",
166+
});
167+
});
168+
169+
it("keeps the same map reference when the default branch is unchanged", () => {
170+
useSettingsStore
171+
.getState()
172+
.setCachedCloudDefaultBranch("posthog/code", "main");
173+
const first = useSettingsStore.getState().cachedCloudDefaultBranchMap;
174+
175+
useSettingsStore
176+
.getState()
177+
.setCachedCloudDefaultBranch("posthog/code", "main");
178+
const second = useSettingsStore.getState().cachedCloudDefaultBranchMap;
179+
180+
expect(second).toBe(first);
181+
});
182+
183+
it("rehydrates the cached cloud default branch map", async () => {
184+
getItem.mockResolvedValue(
185+
JSON.stringify({
186+
state: {
187+
cachedCloudDefaultBranchMap: { "posthog/code": "main" },
188+
},
189+
version: 0,
190+
}),
191+
);
192+
193+
useSettingsStore.setState({ cachedCloudDefaultBranchMap: {} });
194+
195+
await useSettingsStore.persist.rehydrate();
196+
197+
expect(useSettingsStore.getState().cachedCloudDefaultBranchMap).toEqual({
198+
"posthog/code": "main",
199+
});
200+
});
201+
144202
it("rehydrates the unsafe mode toggle", async () => {
145203
getItem.mockResolvedValue(
146204
JSON.stringify({

packages/ui/src/features/settings/settingsStore.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ interface SettingsStore {
107107
lastUsedReasoningEffort: string | null;
108108
lastUsedCloudRepository: string | null;
109109
cachedCloudRepositoryMap: Record<string, UserRepositoryIntegrationRef>;
110+
// Last-known default ("trunk") branch per cloud repo, keyed by lowercased
111+
// "owner/repo". Persisted so a cold start can pre-select trunk in the branch
112+
// picker immediately, before the (slow) live branch list resolves.
113+
cachedCloudDefaultBranchMap: Record<string, string>;
110114
lastUsedEnvironments: Record<string, string>;
111115
defaultInitialTaskMode: DefaultInitialTaskMode;
112116
lastUsedInitialTaskMode: ExecutionMode;
@@ -126,6 +130,7 @@ interface SettingsStore {
126130
setCachedCloudRepositoryMap: (
127131
map: Record<string, UserRepositoryIntegrationRef>,
128132
) => void;
133+
setCachedCloudDefaultBranch: (repo: string, branch: string) => void;
129134
setLastUsedEnvironment: (
130135
repoPath: string,
131136
environmentId: string | null,
@@ -289,6 +294,7 @@ export const useSettingsStore = create<SettingsStore>()(
289294
lastUsedReasoningEffort: null,
290295
lastUsedCloudRepository: null,
291296
cachedCloudRepositoryMap: {},
297+
cachedCloudDefaultBranchMap: {},
292298
lastUsedEnvironments: {},
293299
defaultInitialTaskMode: "plan",
294300
lastUsedInitialTaskMode: "plan",
@@ -308,6 +314,16 @@ export const useSettingsStore = create<SettingsStore>()(
308314
set({ lastUsedCloudRepository: repo }),
309315
setCachedCloudRepositoryMap: (map) =>
310316
set({ cachedCloudRepositoryMap: map }),
317+
setCachedCloudDefaultBranch: (repo, branch) =>
318+
set((state) => {
319+
if (state.cachedCloudDefaultBranchMap[repo] === branch) return {};
320+
return {
321+
cachedCloudDefaultBranchMap: {
322+
...state.cachedCloudDefaultBranchMap,
323+
[repo]: branch,
324+
},
325+
};
326+
}),
311327
setLastUsedEnvironment: (repoPath, environmentId) =>
312328
set((state) => {
313329
const next = { ...state.lastUsedEnvironments };
@@ -498,6 +514,7 @@ export const useSettingsStore = create<SettingsStore>()(
498514
lastUsedReasoningEffort: state.lastUsedReasoningEffort,
499515
lastUsedCloudRepository: state.lastUsedCloudRepository,
500516
cachedCloudRepositoryMap: state.cachedCloudRepositoryMap,
517+
cachedCloudDefaultBranchMap: state.cachedCloudDefaultBranchMap,
501518
lastUsedEnvironments: state.lastUsedEnvironments,
502519
defaultInitialTaskMode: state.defaultInitialTaskMode,
503520
lastUsedInitialTaskMode: state.lastUsedInitialTaskMode,

packages/ui/src/features/task-detail/components/TaskInput.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ export function TaskInput({
189189
setLastUsedAdapter,
190190
lastUsedCloudRepository,
191191
setLastUsedCloudRepository,
192+
cachedCloudDefaultBranchMap,
193+
setCachedCloudDefaultBranch,
192194
allowBypassPermissions,
193195
setLastUsedEnvironment,
194196
getLastUsedEnvironment,
@@ -430,7 +432,33 @@ export function TaskInput({
430432
cloudBranchSearchQuery,
431433
);
432434
const cloudBranches = cloudBranchData?.branches;
433-
const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null;
435+
const liveCloudDefaultBranch = cloudBranchData?.defaultBranch ?? null;
436+
// Serve the persisted default branch until the live list resolves, so the
437+
// majority "start on trunk" case pre-selects trunk with zero wait on a cold
438+
// start. The cached value is best-effort: if it's stale (a default branch
439+
// renamed since it was cached), `cloudDefaultBranch` switches to the live
440+
// value on arrival and BranchSelector re-selects it — as long as the user
441+
// hasn't picked a branch of their own in the meantime.
442+
const cloudDefaultBranch =
443+
liveCloudDefaultBranch ??
444+
(selectedCloudRepository
445+
? (cachedCloudDefaultBranchMap[selectedCloudRepository] ?? null)
446+
: null);
447+
448+
// Persist the freshly loaded default branch so the next cold start can
449+
// pre-select trunk immediately.
450+
useEffect(() => {
451+
if (selectedCloudRepository && liveCloudDefaultBranch) {
452+
setCachedCloudDefaultBranch(
453+
selectedCloudRepository,
454+
liveCloudDefaultBranch,
455+
);
456+
}
457+
}, [
458+
selectedCloudRepository,
459+
liveCloudDefaultBranch,
460+
setCachedCloudDefaultBranch,
461+
]);
434462

435463
const {
436464
branchOpen,

0 commit comments

Comments
 (0)