diff --git a/apps/web/src/components/RightPanelContentBoundary.browser.test.tsx b/apps/web/src/components/RightPanelContentBoundary.browser.test.tsx new file mode 100644 index 0000000000000..b6613e36aaa74 --- /dev/null +++ b/apps/web/src/components/RightPanelContentBoundary.browser.test.tsx @@ -0,0 +1,35 @@ +import { lazy, Suspense } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { RightPanelContentBoundary } from "./RightPanelContentBoundary"; + +it("contains a failed panel download and recovers when another surface opens", async () => { + using _error = vi.spyOn(console, "error").mockImplementation(() => {}); + const FailedPanel = lazy(async () => { + throw new Error("Panel download failed"); + }); + await using screen = await render( + <> +
Running chat
+ + Loading panel}> + + + + , + ); + await expect.element(screen.getByRole("button", { name: "Reload app" })).toBeVisible(); + await expect.element(screen.getByRole("main")).toHaveTextContent("Running chat"); + + await screen.rerender( + <> +
Running chat
+ +

Other panel

+
+ , + ); + await expect.element(screen.getByText("Other panel", { exact: true })).toBeVisible(); + await expect.element(screen.getByRole("main")).toHaveTextContent("Running chat"); +}); diff --git a/apps/web/src/components/RightPanelContentBoundary.tsx b/apps/web/src/components/RightPanelContentBoundary.tsx new file mode 100644 index 0000000000000..eae1bbd096c49 --- /dev/null +++ b/apps/web/src/components/RightPanelContentBoundary.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; + +import { RenderErrorBoundary } from "./RenderErrorBoundary"; +import { Button } from "./ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; + +export function RightPanelContentBoundary(props: { + readonly surfaceId: string; + readonly children: ReactNode; +}) { + return ( + + + Could not load this panel + Reload the app to try again. + + + + } + > + {props.children} + + ); +} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 596bdee96ad1f..b6ca92ee60c72 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -104,6 +104,7 @@ import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; +import { RightPanelContentBoundary } from "./RightPanelContentBoundary"; export interface RightPanelTabsProps { mode: PreviewPanelMode; @@ -1545,7 +1546,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { sideChatDisabledReason={props.sideChatDisabledReason ?? null} /> ) : ( - props.children + + {props.children} + )} diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index b703e5d05da97..782593695a960 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -20,10 +20,18 @@ import { type PersistentThreadTerminalPanelProps, } from "../PersistentThreadTerminals"; import { pullRequestPanelContext } from "../pullRequest/pullRequestDetail.logic"; -import { PullRequestDetailPanel } from "../pullRequest/PullRequestDetailPanel"; -import { PullRequestDetailGhost } from "../pullRequest/PullRequestGhosts"; +import { PullRequestDetailGhost, PullRequestListGhost } from "../pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "../pullRequest/PullRequestsUnavailableState"; -import { ThreadPullRequestsPanel } from "../pullRequest/ThreadPullRequestsPanel"; +const PullRequestDetailPanel = lazy(() => + import("../pullRequest/PullRequestDetailPanel").then((module) => ({ + default: module.PullRequestDetailPanel, + })), +); +const ThreadPullRequestsPanel = lazy(() => + import("../pullRequest/ThreadPullRequestsPanel").then((module) => ({ + default: module.ThreadPullRequestsPanel, + })), +); const PreviewPanel = lazy(() => import("../preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), @@ -128,46 +136,52 @@ export function RightPanelSurfaceContent(props: { ); } return ( - { - useRightPanelStore.getState().openPullRequest(threadRef, { - projectId: reference.projectId, - repository: reference.repository, - number: reference.number, - ...(reference.host ? { host: reference.host } : {}), - }); - }} - threadRef={threadRef} - reference={{ - projectId: surface.projectId as ProjectId, - ...(surface.host ? { host: surface.host } : {}), - repository: surface.repository, - number: surface.number, - }} - initialTab={surface.detailTab ?? "summary"} - onTabChange={(tab) => { - useRightPanelStore.getState().setPullRequestDetailTab(threadRef, surface.id, tab); - }} - context={pullRequestPanelContext( - { - projectId: pullRequest.projectId, - pullRequests: pullRequest.pullRequests, - linkedPullRequest: pullRequest.linkedPullRequest, - branchPullRequest: pullRequest.branchPullRequest, - }, - surface, - )} - composerDraftTarget={props.composerDraftTarget} - onBack={pullRequest.onBack} - /> + }> + { + useRightPanelStore.getState().openPullRequest(threadRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + }); + }} + threadRef={threadRef} + reference={{ + projectId: surface.projectId as ProjectId, + ...(surface.host ? { host: surface.host } : {}), + repository: surface.repository, + number: surface.number, + }} + initialTab={surface.detailTab ?? "summary"} + onTabChange={(tab) => { + useRightPanelStore.getState().setPullRequestDetailTab(threadRef, surface.id, tab); + }} + context={pullRequestPanelContext( + { + projectId: pullRequest.projectId, + pullRequests: pullRequest.pullRequests, + linkedPullRequest: pullRequest.linkedPullRequest, + branchPullRequest: pullRequest.branchPullRequest, + }, + surface, + )} + composerDraftTarget={props.composerDraftTarget} + onBack={pullRequest.onBack} + /> + ); } case "pull-requests": - return ; + return ( + }> + + + ); case "side-chat": // Not lazy: revealing a suspended tiptap editor tears down its view and crashes the composer. return ( diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 2af8383bebcd4..046b49bdf7768 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -21,7 +21,15 @@ import type { import { useAtomValue } from "@effect/atom-react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { ArrowDownUpIcon, LayersIcon, Plug2Icon } from "lucide-react"; -import { useEffect, useEffectEvent, useRef, useState, type ComponentProps } from "react"; +import { + lazy, + Suspense, + useEffect, + useEffectEvent, + useRef, + useState, + type ComponentProps, +} from "react"; import { collectPullRequestListFacets, @@ -53,7 +61,6 @@ import { writePullRequestListPreferences, } from "./pullRequestListPreferences"; import { environmentMachineIcon } from "../EnvironmentMachineIcon"; -import { PullRequestDetailPanel } from "./PullRequestDetailPanel"; import { PullRequestFiltersMenu, PullRequestSearchInput, @@ -75,7 +82,7 @@ import { } from "./usePullRequestListView"; import { pullRequestGroups, pullRequestListEntryId } from "./pullRequestListGroups.logic"; import { PullRequestListEmptyState } from "./PullRequestListEmptyState"; -import { PullRequestListGhost } from "./PullRequestGhosts"; +import { PullRequestDetailGhost, PullRequestListGhost } from "./PullRequestGhosts"; import { type PullRequestRowTarget } from "./PullRequestRow"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import { RightPanelTabs } from "../RightPanelTabs"; @@ -142,6 +149,10 @@ const EMPTY_PREVIEW_SESSIONS = {}; const EMPTY_PREVIEW_DESKTOP_STATE = {}; const EMPTY_TERMINAL_LABELS = new Map(); const EMPTY_PENDING_SURFACES = new Set(); +const PullRequestDetailPanel = lazy(() => + import("./PullRequestDetailPanel").then((module) => ({ default: module.PullRequestDetailPanel })), +); + export function PullRequestsPage() { const search = useSearch({ from: "/_chat/pull-requests" }); const sort = search.sort ?? "ready"; @@ -1517,75 +1528,77 @@ export function PullRequestsPage() { deviceAvailable={false} pullRequestStatusSeeds={listedPullRequestTabStatuses} > - { - if (rightPanelRef === null) return; - useRightPanelStore.getState().openPullRequest(rightPanelRef, { - projectId: reference.projectId, - repository: reference.repository, - number: reference.number, - ...(reference.host ? { host: reference.host } : {}), - environmentId: panelEnvironmentId, - }); - updateSearch({ - repository: reference.repository, - number: reference.number, - selectedHost: reference.host, - selectedProjectId: reference.projectId, - selectedEnvironmentId: panelEnvironmentId, - }); - }} - reference={pullRequestPanelReference({ - ...renderedPullRequestSurface, - projectId: renderedPullRequestSurface.projectId as ProjectId, - })} - listEntry={ - listedPullRequestsBySurface.get( - pullRequestListEntryId(renderedPullRequestSurface), - ) ?? null - } - refreshToken={detailRefreshToken} - tabOnPage - onActed={(action, phase = "done", reply) => { - // From every row held, not the ones on screen: a pull request closed from an open - // list has left the screen, and reopening it has to find it anyway. - const acted = heldPullRequestsBySurface.get( - pullRequestListEntryId(renderedPullRequestSurface), - ); - if (reply !== undefined) { - if (acted !== undefined) { - if (action !== undefined) overrideEntry(acted, action, reply); - detailOverrideTokens.current.delete(pullRequestEntryKey(acted)); - } - return; + }> + { + if (rightPanelRef === null) return; + useRightPanelStore.getState().openPullRequest(rightPanelRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + environmentId: panelEnvironmentId, + }); + updateSearch({ + repository: reference.repository, + number: reference.number, + selectedHost: reference.host, + selectedProjectId: reference.projectId, + selectedEnvironmentId: panelEnvironmentId, + }); + }} + reference={pullRequestPanelReference({ + ...renderedPullRequestSurface, + projectId: renderedPullRequestSurface.projectId as ProjectId, + })} + listEntry={ + listedPullRequestsBySurface.get( + pullRequestListEntryId(renderedPullRequestSurface), + ) ?? null } - const stateOnly = - action !== undefined && - acted !== undefined && - pullRequestOverrideAfterAction(acted, action, new Date(), 0) !== null; - if (stateOnly) { - const key = pullRequestEntryKey(acted); - if (phase === "sent" && action !== "merge") { - detailOverrideTokens.current.set(key, overrideEntry(acted, action)); + refreshToken={detailRefreshToken} + tabOnPage + onActed={(action, phase = "done", reply) => { + // From every row held, not the ones on screen: a pull request closed from an open + // list has left the screen, and reopening it has to find it anyway. + const acted = heldPullRequestsBySurface.get( + pullRequestListEntryId(renderedPullRequestSurface), + ); + if (reply !== undefined) { + if (acted !== undefined) { + if (action !== undefined) overrideEntry(acted, action, reply); + detailOverrideTokens.current.delete(pullRequestEntryKey(acted)); + } + return; } - if (phase === "failed" && action !== "merge") { - revertOverride(key, detailOverrideTokens.current.get(key) ?? null); + const stateOnly = + action !== undefined && + acted !== undefined && + pullRequestOverrideAfterAction(acted, action, new Date(), 0) !== null; + if (stateOnly) { + const key = pullRequestEntryKey(acted); + if (phase === "sent" && action !== "merge") { + detailOverrideTokens.current.set(key, overrideEntry(acted, action)); + } + if (phase === "failed" && action !== "merge") { + revertOverride(key, detailOverrideTokens.current.get(key) ?? null); + } + if (phase !== "sent") detailOverrideTokens.current.delete(key); + if (phase === "done" && action === "merge") { + overrideEntry(acted, action); + refreshListAndStats(undefined, panelEnvironmentId); + } + return; } - if (phase !== "sent") detailOverrideTokens.current.delete(key); - if (phase === "done" && action === "merge") { - overrideEntry(acted, action); - refreshListAndStats(undefined, panelEnvironmentId); - } - return; - } - if (phase === "done") refreshListAndStats(undefined, panelEnvironmentId); - }} - /> + if (phase === "done") refreshListAndStats(undefined, panelEnvironmentId); + }} + /> + ) : null}