From 2504d1d2bc422ce604aa67eb26c63a98d4c3e18f Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Thu, 16 Jul 2026 10:57:55 +0100 Subject: [PATCH 1/4] feat(task-input): cache cloud default branch for instant "start on trunk" Persist the last-known default ("trunk") branch per cloud repo so a cold start can pre-select trunk in the branch picker immediately, instead of waiting on the slow live branch list to resolve. The picker already auto-selects the default branch when nothing is selected; feeding it a cached value makes that fire with zero wait. The fresh value from the live query takes over the moment it arrives and is written back to the cache. Generated-By: PostHog Code Task-Id: 562fb90f-d823-425d-b53c-5f482b55a8b2 --- .../features/settings/settingsStore.test.ts | 58 +++++++++++++++++++ .../ui/src/features/settings/settingsStore.ts | 17 ++++++ .../task-detail/components/TaskInput.tsx | 27 ++++++++- 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/settings/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts index 26376f6504..208280e7bd 100644 --- a/packages/ui/src/features/settings/settingsStore.test.ts +++ b/packages/ui/src/features/settings/settingsStore.test.ts @@ -58,6 +58,7 @@ describe("feature settingsStore cloud selections", () => { allowBypassPermissions: false, lastUsedCloudRepository: null, cachedCloudRepositoryMap: {}, + cachedCloudDefaultBranchMap: {}, }); }); @@ -141,6 +142,63 @@ describe("feature settingsStore cloud selections", () => { }); }); + it("caches and persists the cloud default branch per repo", async () => { + useSettingsStore + .getState() + .setCachedCloudDefaultBranch("posthog/posthog", "master"); + useSettingsStore + .getState() + .setCachedCloudDefaultBranch("posthog/code", "main"); + + expect(useSettingsStore.getState().cachedCloudDefaultBranchMap).toEqual({ + "posthog/posthog": "master", + "posthog/code": "main", + }); + + await waitForPersistedWrite(); + + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + const persisted = JSON.parse(lastCall[1]); + + expect(persisted.state.cachedCloudDefaultBranchMap).toEqual({ + "posthog/posthog": "master", + "posthog/code": "main", + }); + }); + + it("keeps the same map reference when the default branch is unchanged", () => { + useSettingsStore + .getState() + .setCachedCloudDefaultBranch("posthog/code", "main"); + const first = useSettingsStore.getState().cachedCloudDefaultBranchMap; + + useSettingsStore + .getState() + .setCachedCloudDefaultBranch("posthog/code", "main"); + const second = useSettingsStore.getState().cachedCloudDefaultBranchMap; + + expect(second).toBe(first); + }); + + it("rehydrates the cached cloud default branch map", async () => { + getItem.mockResolvedValue( + JSON.stringify({ + state: { + cachedCloudDefaultBranchMap: { "posthog/code": "main" }, + }, + version: 0, + }), + ); + + useSettingsStore.setState({ cachedCloudDefaultBranchMap: {} }); + + await useSettingsStore.persist.rehydrate(); + + expect(useSettingsStore.getState().cachedCloudDefaultBranchMap).toEqual({ + "posthog/code": "main", + }); + }); + it("rehydrates the unsafe mode toggle", async () => { getItem.mockResolvedValue( JSON.stringify({ diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 44914f4501..e8181760bb 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -107,6 +107,10 @@ interface SettingsStore { lastUsedReasoningEffort: string | null; lastUsedCloudRepository: string | null; cachedCloudRepositoryMap: Record; + // Last-known default ("trunk") branch per cloud repo, keyed by lowercased + // "owner/repo". Persisted so a cold start can pre-select trunk in the branch + // picker immediately, before the (slow) live branch list resolves. + cachedCloudDefaultBranchMap: Record; lastUsedEnvironments: Record; defaultInitialTaskMode: DefaultInitialTaskMode; lastUsedInitialTaskMode: ExecutionMode; @@ -126,6 +130,7 @@ interface SettingsStore { setCachedCloudRepositoryMap: ( map: Record, ) => void; + setCachedCloudDefaultBranch: (repo: string, branch: string) => void; setLastUsedEnvironment: ( repoPath: string, environmentId: string | null, @@ -289,6 +294,7 @@ export const useSettingsStore = create()( lastUsedReasoningEffort: null, lastUsedCloudRepository: null, cachedCloudRepositoryMap: {}, + cachedCloudDefaultBranchMap: {}, lastUsedEnvironments: {}, defaultInitialTaskMode: "plan", lastUsedInitialTaskMode: "plan", @@ -308,6 +314,16 @@ export const useSettingsStore = create()( set({ lastUsedCloudRepository: repo }), setCachedCloudRepositoryMap: (map) => set({ cachedCloudRepositoryMap: map }), + setCachedCloudDefaultBranch: (repo, branch) => + set((state) => { + if (state.cachedCloudDefaultBranchMap[repo] === branch) return {}; + return { + cachedCloudDefaultBranchMap: { + ...state.cachedCloudDefaultBranchMap, + [repo]: branch, + }, + }; + }), setLastUsedEnvironment: (repoPath, environmentId) => set((state) => { const next = { ...state.lastUsedEnvironments }; @@ -498,6 +514,7 @@ export const useSettingsStore = create()( lastUsedReasoningEffort: state.lastUsedReasoningEffort, lastUsedCloudRepository: state.lastUsedCloudRepository, cachedCloudRepositoryMap: state.cachedCloudRepositoryMap, + cachedCloudDefaultBranchMap: state.cachedCloudDefaultBranchMap, lastUsedEnvironments: state.lastUsedEnvironments, defaultInitialTaskMode: state.defaultInitialTaskMode, lastUsedInitialTaskMode: state.lastUsedInitialTaskMode, diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 1f7981cb2c..5fb834a9d5 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -189,6 +189,8 @@ export function TaskInput({ setLastUsedAdapter, lastUsedCloudRepository, setLastUsedCloudRepository, + cachedCloudDefaultBranchMap, + setCachedCloudDefaultBranch, allowBypassPermissions, setLastUsedEnvironment, getLastUsedEnvironment, @@ -430,7 +432,30 @@ export function TaskInput({ cloudBranchSearchQuery, ); const cloudBranches = cloudBranchData?.branches; - const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; + const liveCloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; + // Serve the persisted default branch until the live list resolves, so the + // majority "start on trunk" case pre-selects trunk with zero wait on a cold + // start. Falls through to the fresh value the moment it arrives. + const cloudDefaultBranch = + liveCloudDefaultBranch ?? + (selectedCloudRepository + ? (cachedCloudDefaultBranchMap[selectedCloudRepository] ?? null) + : null); + + // Persist the freshly loaded default branch so the next cold start can + // pre-select trunk immediately. + useEffect(() => { + if (selectedCloudRepository && liveCloudDefaultBranch) { + setCachedCloudDefaultBranch( + selectedCloudRepository, + liveCloudDefaultBranch, + ); + } + }, [ + selectedCloudRepository, + liveCloudDefaultBranch, + setCachedCloudDefaultBranch, + ]); const { branchOpen, From 593d7444c685322b0ff5299b82ca23b72c121fb3 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Thu, 16 Jul 2026 11:16:53 +0100 Subject: [PATCH 2/4] feat(branch-selector): show trunk + loading row in the open cloud picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed the known default ("trunk") branch as a real, pickable list item in the cloud branch picker while the remote branch list is still loading, with a "Loading branches…" row rendered directly below it. This makes the open dropdown usable for the common "start on trunk" case with zero wait, and signals that the rest of the branches are still on the way. Only seeds when there's no active search — once the user types, the list is purely what the remote returns. Generated-By: PostHog Code Task-Id: 562fb90f-d823-425d-b53c-5f482b55a8b2 --- .../components/BranchSelector.test.tsx | 44 +++++++++++++++++++ .../components/BranchSelector.tsx | 25 ++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx b/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx index 1dc5bb9428..3add6f2086 100644 --- a/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx +++ b/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx @@ -75,6 +75,50 @@ describe("BranchSelector cloud mode", () => { expect(screen.getByRole("combobox", { name: "Branch" })).toBeEnabled(); }); + it("seeds the default branch as a pickable item with a loading row while the cloud list loads", async () => { + const user = userEvent.setup(); + renderInTheme( + , + ); + + await user.click(screen.getByRole("combobox", { name: "Branch" })); + + expect(await screen.findByRole("option", { name: "main" })).toBeVisible(); + expect(screen.getByText("Loading branches…")).toBeVisible(); + }); + + it("does not seed the default branch once the user is searching", async () => { + const user = userEvent.setup(); + renderInTheme( + , + ); + + await user.click(screen.getByRole("combobox", { name: "Branch" })); + + expect(screen.queryByRole("option", { name: "main" })).toBeNull(); + }); + it("surfaces the 'Use input as branch name' action when the typed value is new", async () => { const user = userEvent.setup(); renderInTheme( diff --git a/packages/ui/src/features/git-interaction/components/BranchSelector.tsx b/packages/ui/src/features/git-interaction/components/BranchSelector.tsx index ec71a24d32..096c1ea721 100644 --- a/packages/ui/src/features/git-interaction/components/BranchSelector.tsx +++ b/packages/ui/src/features/git-interaction/components/BranchSelector.tsx @@ -175,12 +175,28 @@ export function BranchSelector({ return byBranch; }, [repoCheckouts]); - const branches = isCloudMode ? (cloudBranches ?? []) : localBranches; + const liveBranches = isCloudMode ? (cloudBranches ?? []) : localBranches; const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading); const branchListLoading = isCloudMode ? !!cloudBranchesLoading : localBranchesLoading; + // On a cold start the live cloud branch list is still empty while the (slow) + // remote fetch runs. Surface the known default ("trunk") branch as a real + // list item straight away — with a loading row rendered below it — so the + // common "start on trunk" case is pickable with zero wait. Only when there's + // no active search: once the user types, the results should be purely what + // the remote returns. + const seededDefaultBranch = + isCloudMode && + branchListLoading && + liveBranches.length === 0 && + !!defaultBranch && + !(cloudSearchQuery ?? "").trim() + ? defaultBranch + : null; + const branches = seededDefaultBranch ? [seededDefaultBranch] : liveBranches; + const checkoutMutation = useMutation({ ...trpc.git.checkoutBranch.mutationOptions(), onSuccess: () => { @@ -519,6 +535,13 @@ export function BranchSelector({ }} + {/* + Cold start: the default ("trunk") branch is seeded as the only list + item while the remote list loads. A loading row directly below it + makes clear the rest of the branches are still on the way. + */} + {seededDefaultBranch ? : null} + {isCloudMode && cloudBranchesHasMore ? (
From 6b73ec28891640608beff44893f505925573f9dd Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Thu, 16 Jul 2026 11:45:30 +0100 Subject: [PATCH 3/4] refactor(branch-selector): address qa-swarm review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-perspective review (paul + xp + security-audit) surfaced low/nit items; this addresses the actionable ones: - Extract the duplicated "Loading branches…" literal into a shared LOADING_BRANCHES_LABEL const so the two render sites can't drift. - Clarify the TaskInput comment: the cached default is best-effort — a default branch renamed since it was cached stays seeded until the user picks another (the auto-select only fires while nothing is selected). - Lock the empty-state gating with a test asserting "No branches found." keeps the `hidden` class when the seeded trunk row makes the list non-empty, so it never flashes above the seeded row. security-audit: 0 findings (API-derived branch name → auto-escaped React text + the pre-existing selection path; no new sink). Generated-By: PostHog Code Task-Id: 562fb90f-d823-425d-b53c-5f482b55a8b2 --- .../git-interaction/components/BranchSelector.test.tsx | 5 +++++ .../git-interaction/components/BranchSelector.tsx | 10 ++++++++-- .../src/features/task-detail/components/TaskInput.tsx | 5 ++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx b/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx index 3add6f2086..e80fcb32aa 100644 --- a/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx +++ b/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx @@ -96,6 +96,11 @@ describe("BranchSelector cloud mode", () => { expect(await screen.findByRole("option", { name: "main" })).toBeVisible(); expect(screen.getByText("Loading branches…")).toBeVisible(); + // The seeded row makes the list non-empty, so the empty-state stays gated + // off (Base UI only reveals it when the content group is data-empty) — it + // keeps the `hidden` class rather than flashing "No branches found." above + // the trunk row. + expect(screen.getByText("No branches found.")).toHaveClass("hidden"); }); it("does not seed the default branch once the user is searching", async () => { diff --git a/packages/ui/src/features/git-interaction/components/BranchSelector.tsx b/packages/ui/src/features/git-interaction/components/BranchSelector.tsx index 096c1ea721..db661badec 100644 --- a/packages/ui/src/features/git-interaction/components/BranchSelector.tsx +++ b/packages/ui/src/features/git-interaction/components/BranchSelector.tsx @@ -40,6 +40,10 @@ import { getSuggestedBranchName } from "../utils/getSuggestedBranchName"; const COMBOBOX_LIMIT = 50; +// Shared so the two "still loading branches" render sites (the empty-list +// spinner and the seeded-default row) can never drift out of sync on a copy edit. +const LOADING_BRANCHES_LABEL = "Loading branches…"; + // Sentinel value for the "Create new branch" action. Rendered as a real // ComboboxItem in the list footer so it's reachable by keyboard, not a // plain button the combobox's roving focus skips over. @@ -472,7 +476,7 @@ export function BranchSelector({ ) : null} {branchListLoading && branches.length === 0 ? ( - + ) : ( No branches found. )} @@ -540,7 +544,9 @@ export function BranchSelector({ item while the remote list loads. A loading row directly below it makes clear the rest of the branches are still on the way. */} - {seededDefaultBranch ? : null} + {seededDefaultBranch ? ( + + ) : null} {isCloudMode && cloudBranchesHasMore ? ( diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 5fb834a9d5..5bde34c47a 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -435,7 +435,10 @@ export function TaskInput({ const liveCloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; // Serve the persisted default branch until the live list resolves, so the // majority "start on trunk" case pre-selects trunk with zero wait on a cold - // start. Falls through to the fresh value the moment it arrives. + // start. The cached value is best-effort: `cloudDefaultBranch` switches to the + // live value the moment it arrives, but the picker's auto-select only fires + // while nothing is selected — so a default branch renamed since it was cached + // (rare) would leave the seeded name selected until the user picks another. const cloudDefaultBranch = liveCloudDefaultBranch ?? (selectedCloudRepository From dc1f75fb60d65b10259386c93973e22743333ac9 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Thu, 16 Jul 2026 11:53:40 +0100 Subject: [PATCH 4/4] fix(branch-selector): self-heal a stale cached default branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four reviewers (qa-swarm's paul + xp, plus the org Codex and veria-ai bots) independently flagged that a stale cached default branch never self-corrects: the picker auto-selects the cached "trunk", and the existing effect's `!selectedBranch` guard then blocks re-selection once the live default arrives, so a task could start on a branch that is no longer the default. Track the branch we auto-selected in a ref and re-select when the default changes out from under our own auto-pick — while never clobbering a branch the user chose deliberately. Adds tests for both the self-heal and the user-choice-preserved paths, and updates the TaskInput comment to match. Generated-By: PostHog Code Task-Id: 562fb90f-d823-425d-b53c-5f482b55a8b2 --- .../components/BranchSelector.test.tsx | 78 +++++++++++++++++++ .../components/BranchSelector.tsx | 13 +++- .../task-detail/components/TaskInput.tsx | 8 +- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx b/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx index e80fcb32aa..69f09ecc7f 100644 --- a/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx +++ b/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx @@ -124,6 +124,84 @@ describe("BranchSelector cloud mode", () => { expect(screen.queryByRole("option", { name: "main" })).toBeNull(); }); + it("re-selects the default when a stale cached default is replaced by the live one", () => { + const onBranchSelect = vi.fn(); + const { rerender } = renderInTheme( + , + ); + + // Auto-selected the (stale) cached default. + expect(onBranchSelect).toHaveBeenLastCalledWith("master"); + + // Parent commits that selection; then the live default arrives differing. + rerender( + + + , + ); + + expect(onBranchSelect).toHaveBeenLastCalledWith("main"); + }); + + it("does not override a branch the user picked when the default later changes", () => { + const onBranchSelect = vi.fn(); + const { rerender } = renderInTheme( + , + ); + + rerender( + + + , + ); + + expect(onBranchSelect).not.toHaveBeenCalled(); + }); + it("surfaces the 'Use input as branch name' action when the typed value is new", async () => { const user = userEvent.setup(); renderInTheme( diff --git a/packages/ui/src/features/git-interaction/components/BranchSelector.tsx b/packages/ui/src/features/git-interaction/components/BranchSelector.tsx index db661badec..cca7e1753d 100644 --- a/packages/ui/src/features/git-interaction/components/BranchSelector.tsx +++ b/packages/ui/src/features/git-interaction/components/BranchSelector.tsx @@ -144,8 +144,19 @@ export function BranchSelector({ const isSelectionOnly = workspaceMode === "worktree" || isCloudMode; const displayedBranch = isSelectionOnly ? selectedBranch : currentBranch; + // The branch we auto-selected, so we can tell our own pick apart from one the + // user made. Lets us correct a stale default (e.g. a cached "trunk" that the + // live list later contradicts) without ever clobbering a deliberate choice. + const autoSelectedBranchRef = useRef(null); useEffect(() => { - if (isSelectionOnly && defaultBranch && !selectedBranch && onBranchSelect) { + if (!isSelectionOnly || !defaultBranch || !onBranchSelect) return; + // Adopt the default when nothing is selected yet, or when the default has + // changed out from under a value we ourselves auto-selected — but leave a + // user's own selection alone. + const selectionIsOurs = + !selectedBranch || selectedBranch === autoSelectedBranchRef.current; + if (selectionIsOurs && selectedBranch !== defaultBranch) { + autoSelectedBranchRef.current = defaultBranch; onBranchSelect(defaultBranch); } }, [isSelectionOnly, defaultBranch, selectedBranch, onBranchSelect]); diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 5bde34c47a..ac95290dca 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -435,10 +435,10 @@ export function TaskInput({ const liveCloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; // Serve the persisted default branch until the live list resolves, so the // majority "start on trunk" case pre-selects trunk with zero wait on a cold - // start. The cached value is best-effort: `cloudDefaultBranch` switches to the - // live value the moment it arrives, but the picker's auto-select only fires - // while nothing is selected — so a default branch renamed since it was cached - // (rare) would leave the seeded name selected until the user picks another. + // start. The cached value is best-effort: if it's stale (a default branch + // renamed since it was cached), `cloudDefaultBranch` switches to the live + // value on arrival and BranchSelector re-selects it — as long as the user + // hasn't picked a branch of their own in the meantime. const cloudDefaultBranch = liveCloudDefaultBranch ?? (selectedCloudRepository