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}