From 616f5e155c38cc7dcc015db15781d9b13c79bce1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 21:35:20 +0700 Subject: [PATCH] fix(app): show branch loading and fetch errors (#8938) * fix(app): distinguish branch loading from empty results Preserve branch query pending and error states through the switcher so first open does not report missing branches before the response. Show request failures with a retry action. * fix(app): keep branch retry progress and keyboard focus Verify failed cached refresh and keyboard retry through the real browser and daemon. Keep focus in the footer while Retry is disabled so repeated Enter cannot select a cached branch. * fix(app): preserve picker interaction when retrying branch loads Use the existing close-and-reopen retry behavior with clear feedback rather than introducing an action that competes with native list keyboard ownership. --- .../app/e2e/browser/branch-switcher.spec.ts | 88 +++++++++++++++++++ .../app/src/components/branch-switcher.tsx | 32 ++++++- packages/app/src/hooks/use-branch-switcher.ts | 14 ++- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + 12 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/browser/branch-switcher.spec.ts b/packages/app/e2e/browser/branch-switcher.spec.ts index c413b6413e..9799242390 100644 --- a/packages/app/e2e/browser/branch-switcher.spec.ts +++ b/packages/app/e2e/browser/branch-switcher.spec.ts @@ -1,4 +1,7 @@ +import { writeFile, unlink } from "node:fs/promises"; +import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { gotoAppShell } from "../support/helpers/app"; import { expectNoBranchSwitcherInWorkspaceHeader, @@ -14,6 +17,88 @@ import { waitForSidebarHydration, } from "../support/helpers/workspace-ui"; +// Hold the real daemon response until the test has observed the pending UI. +async function holdBranchSuggestions(page: Page) { + let requestCount = 0; + let release!: () => void; + let gate = new Promise((resolve) => { + release = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((message) => { + const envelope = JSON.parse(message.toString()); + if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request") + requestCount++; + server.send(message); + }); + server.onMessage(async (message) => { + const envelope = JSON.parse(message.toString()); + if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") { + await gate; + } + browser.send(message); + }); + }); + return { + release: () => release(), + holdNextResponse: () => { + gate = new Promise((resolve) => { + release = resolve; + }); + }, + requestCount: () => requestCount, + }; +} + +async function expectInitialBranchLoading(page: Page, release: () => void): Promise { + await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click(); + const picker = page.getByTestId("combobox-desktop-container"); + await expect(picker.getByText("Loading...", { exact: true })).toBeVisible(); + await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0); + release(); + await expect(picker.getByText("dev", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(picker).not.toBeVisible(); +} + +async function expectFailedRefreshRecovery( + page: Page, + repoPath: string, + responses: Awaited>, +) { + // Expire the actual query freshness window before reopening the cached picker. + const started = Date.now(); + await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000); + const picker = page.getByTestId("combobox-desktop-container"); + const packedRefs = path.join(repoPath, ".git", "packed-refs"); + await writeFile(packedRefs, "invalid packed refs\n"); + try { + await page + .getByRole("button", { name: "Current branch: dev. Press to switch branch." }) + .click(); + await expect(picker.getByRole("alert")).toHaveText( + "Could not load branches. Reopen the picker to try again.", + ); + } finally { + await unlink(packedRefs); + } + await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0); + responses.holdNextResponse(); + const beforeRetry = responses.requestCount(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click(); + await expect(picker.getByRole("alert")).toHaveText("Loading..."); + await expect.poll(responses.requestCount).toBe(beforeRetry + 1); + await expect(picker).toBeVisible(); + responses.release(); + await expect(picker.getByRole("alert")).toHaveCount(0); + await expect(picker.getByText("main", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expectWorkspaceBranch(page, "dev"); + expect(responses.requestCount()).toBe(beforeRetry + 1); +} + async function renameWorkspaceViaSidebar( page: Page, input: { workspaceId: string; title: string }, @@ -59,6 +144,7 @@ test.describe("Branch switcher", () => { try { expect(workspace.workspaceName).toBe("main"); + const releaseBranches = await holdBranchSuggestions(page); await gotoAppShell(page); await waitForSidebarHydration(page); await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId }); @@ -82,8 +168,10 @@ test.describe("Branch switcher", () => { // and switching it checks out the real branch on disk. await openChangesPanel(page); await expectWorkspaceBranch(page, "main"); + await expectInitialBranchLoading(page, releaseBranches.release); await switchBranchFromChangesPanel(page, { from: "main", to: "dev" }); await expectWorkspaceBranch(page, "dev"); + await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches); // The custom title is unaffected by the branch switch. await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 }); diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index 737035d540..2b23ff775e 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -41,7 +41,15 @@ export function BranchSwitcher({ const toast = useToast(); const queryClient = useQueryClient(); - const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({ + const { + branchOptions, + isLoadingBranches, + isFetchingBranches, + branchError, + isOpen, + setIsOpen, + handleBranchSelect, + } = useBranchSwitcher({ client, normalizedServerId: serverId, normalizedWorkspaceId: workspaceId, @@ -73,6 +81,22 @@ export function BranchSwitcher({ [branchLeadingSlot], ); + let emptyText = t("branchSwitcher.empty"); + if (isLoadingBranches) emptyText = t("common.states.loading"); + else if (branchError) emptyText = ""; + + const errorFooter = useMemo( + () => + branchError ? ( + + + {isFetchingBranches ? t("common.states.loading") : t("branchSwitcher.failedToLoad")} + + + ) : undefined, + [branchError, isFetchingBranches, t], + ); + if (!currentBranchName) { return null; } @@ -85,6 +109,7 @@ export function BranchSwitcher({ testID={testID} label={currentBranchName} open={isOpen} + disabled={!isConnected} onPress={handleOpen} accessibilityRole="button" accessibilityLabel={t("branchSwitcher.currentBranch", { @@ -103,7 +128,8 @@ export function BranchSwitcher({ searchable placeholder={t("branchSwitcher.placeholder")} searchPlaceholder={t("branchSwitcher.searchPlaceholder")} - emptyText={t("branchSwitcher.empty")} + emptyText={emptyText} + footer={errorFooter} title={t("branchSwitcher.title")} open={isOpen} onOpenChange={setIsOpen} @@ -122,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, minWidth: 0, }, + footer: { padding: theme.spacing[3], gap: theme.spacing[2] }, + error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm }, tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index e8e33b7f25..5a47c5117e 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -22,6 +22,9 @@ interface UseBranchSwitcherInput { interface UseBranchSwitcherResult { branchOptions: ComboboxOption[]; + isLoadingBranches: boolean; + isFetchingBranches: boolean; + branchError: Error | null; isOpen: boolean; setIsOpen: (open: boolean) => void; handleBranchSelect: (branchId: string) => void; @@ -185,5 +188,14 @@ export function useBranchSwitcher({ ], ); - return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout }; + return { + branchOptions, + isLoadingBranches: branchSuggestionsQuery.isPending, + isFetchingBranches: branchSuggestionsQuery.isFetching, + branchError: branchSuggestionsQuery.error, + isOpen, + setIsOpen, + handleBranchSelect, + invalidateStashAndCheckout, + }; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b731187128..48e7f15d4f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1619,6 +1619,7 @@ export const ar: TranslationResources = { openPath: "فتح المسار", }, branchSwitcher: { + failedToLoad: "تعذّر تحميل الفروع. أعد فتح قائمة الفروع للمحاولة مرة أخرى.", triggerTooltip: "تبديل فرع مساحة العمل", currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.", placeholder: "تبديل الفرع...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bd148ca12b..bf1444d3ad 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1649,6 +1649,7 @@ export const en = { placeholder: "Switch branch...", searchPlaceholder: "Filter branches...", empty: "No branches found.", + failedToLoad: "Could not load branches. Reopen the picker to try again.", title: "Switch branch", uncommittedTitle: "Uncommitted changes", uncommittedMessage: "You have uncommitted changes. Stash them before switching branches?", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index e662f7624e..cf2534f323 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1665,6 +1665,7 @@ export const es: TranslationResources = { openPath: "Abrir ruta", }, branchSwitcher: { + failedToLoad: "No se pudieron cargar las ramas. Vuelve a abrir el selector para reintentarlo.", triggerTooltip: "Cambiar rama del espacio de trabajo", currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.", placeholder: "Cambiar de rama...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 3eee928d57..73acbfb9e8 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1661,6 +1661,7 @@ export const fr: TranslationResources = { openPath: "Ouvrir le chemin", }, branchSwitcher: { + failedToLoad: "Impossible de charger les branches. Rouvrez le sélecteur pour réessayer.", triggerTooltip: "Changer la branche de l’espace de travail", currentBranch: "Branche actuelle : {{branchName}}. Appuyez pour changer de branche.", placeholder: "Changer de branche…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index fbf3fd7ab8..a845f43303 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1636,6 +1636,7 @@ export const ja: TranslationResources = { openPath: "パスを開く", }, branchSwitcher: { + failedToLoad: "ブランチを読み込めませんでした。選択画面を開き直してお試しください。", triggerTooltip: "ワークスペースのブランチを切り替え", currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。", placeholder: "ブランチを切り替え...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 94272f7143..033affa05e 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1629,6 +1629,7 @@ export const ko: TranslationResources = { openPath: "경로 열기", }, branchSwitcher: { + failedToLoad: "브랜치를 불러오지 못했습니다. 선택기를 다시 열어 재시도하세요.", triggerTooltip: "워크스페이스 브랜치 전환", currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.", placeholder: "브랜치 전환...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e8d3b166b8..c48f2974bc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1650,6 +1650,7 @@ export const ptBR: TranslationResources = { openPath: "Abrir caminho", }, branchSwitcher: { + failedToLoad: "Não foi possível carregar as branches. Reabra o seletor para tentar novamente.", triggerTooltip: "Trocar branch do workspace", currentBranch: "Branch atual: {{branchName}}. Pressione para trocar de branch.", placeholder: "Trocar branch...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f9eda82e87..226c03cfe4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1647,6 +1647,7 @@ export const ru: TranslationResources = { openPath: "Открыть путь", }, branchSwitcher: { + failedToLoad: "Не удалось загрузить ветки. Откройте список заново, чтобы повторить попытку.", triggerTooltip: "Переключить ветку рабочего пространства", currentBranch: "Текущая ветка: {{branchName}}. Нажмите, чтобы переключить ветку.", placeholder: "Сменить ветку...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 16b5e2cfa9..f95fdc9dfb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1602,6 +1602,7 @@ export const zhCN: TranslationResources = { openPath: "打开路径", }, branchSwitcher: { + failedToLoad: "无法加载分支,请重新打开选择器以重试。", triggerTooltip: "切换工作区分支", currentBranch: "当前分支:{{branchName}}。按下以切换分支。", placeholder: "切换分支...",