Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
920770b
refactor(web): register the Diff side panel
saphid Oct 2, 2026
e1ae808
refactor(web): register the Preview side panel
github-actions[bot] Oct 4, 2026
4851ae8
perf(web): keep the panel host stable across chat view renders
github-actions[bot] Oct 7, 2026
30972c6
fix(web): send a late annotation only to the thread it was picked in
github-actions[bot] Oct 7, 2026
52a78e7
fix(web): lend the annotation sender only after the chat view commits
github-actions[bot] Oct 7, 2026
7be7fb5
refactor(web): move the thread terminal panel and drawer out of ChatView
github-actions[bot] Oct 4, 2026
a0ea164
refactor(web): open the right-panel terminal through the panel host
github-actions[bot] Oct 4, 2026
02d7fe8
fix(web): keep a local-checkout terminal launch off the thread's work…
github-actions[bot] Oct 6, 2026
ca03345
fix(web): keep a terminal the server opened on the checkout off the t…
github-actions[bot] Oct 7, 2026
4b7aa2e
fix(web): keep device results in the thread that started them
github-actions[bot] Oct 4, 2026
a1d256b
refactor(web): open the Device side panel through the panel host
github-actions[bot] Oct 4, 2026
bcb7b41
refactor(web): open the pull request side panels through the panel host
github-actions[bot] Oct 4, 2026
cfd56f6
test(web): build the pull request panel fixture against current main
github-actions[bot] Oct 11, 2026
c93a18c
Merge upstream main and drop the stacked layers from this branch
github-actions[bot] Oct 14, 2026
6c1b6df
perf(web): load the pull request panels only when opened
github-actions[bot] Oct 14, 2026
39197c7
fix(web): show the list ghost while the linked pull requests panel loads
github-actions[bot] Oct 14, 2026
9e86a21
chore: integrate upstream main for perf(web): load the pull request p…
github-actions[bot] Oct 17, 2026
e984b0a
merge: preserve lazy pull request panels and current detail tab state
saphid Oct 20, 2026
30417ce
perf(web): integrate main while preserving lazy pull request chunks
saphid Oct 23, 2026
9cd48e4
fix(web): integrate current main into lazy pull request panels
saphid Oct 31, 2026
0b04d2d
fix(web): preserve side chats while updating panel #16042
saphid Oct 31, 2026
5bdc9e3
fix(web): complete pull request panel merge integration
saphid Oct 31, 2026
ad71d67
perf(web): defer standalone PR detail loading
saphid Oct 31, 2026
03ec00d
fix(web): contain failed right-panel downloads
saphid Oct 31, 2026
a5d65df
Merge remote-tracking branch 'github/main' into stack/05-pr-panel
maria-rcks Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/web/src/components/RightPanelContentBoundary.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<>
<main>Running chat</main>
<RightPanelContentBoundary surfaceId="failed-pr">
<Suspense fallback={<span>Loading panel</span>}>
<FailedPanel />
</Suspense>
</RightPanelContentBoundary>
</>,
);
await expect.element(screen.getByRole("button", { name: "Reload app" })).toBeVisible();
await expect.element(screen.getByRole("main")).toHaveTextContent("Running chat");

await screen.rerender(
<>
<main>Running chat</main>
<RightPanelContentBoundary surfaceId="other-panel">
<p>Other panel</p>
</RightPanelContentBoundary>
</>,
);
await expect.element(screen.getByText("Other panel", { exact: true })).toBeVisible();
await expect.element(screen.getByRole("main")).toHaveTextContent("Running chat");
});
29 changes: 29 additions & 0 deletions apps/web/src/components/RightPanelContentBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<RenderErrorBoundary
resetKeys={[props.surfaceId]}
fallback={
<Empty>
<EmptyHeader>
<EmptyTitle>Could not load this panel</EmptyTitle>
<EmptyDescription>Reload the app to try again.</EmptyDescription>
</EmptyHeader>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Reload app
</Button>
</Empty>
}
>
{props.children}
</RenderErrorBoundary>
);
}
5 changes: 4 additions & 1 deletion apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1545,7 +1546,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
sideChatDisabledReason={props.sideChatDisabledReason ?? null}
/>
) : (
props.children
<RightPanelContentBoundary surfaceId={props.activeSurfaceId}>
{props.children}
</RightPanelContentBoundary>
)}
</div>
</PreviewPanelShell>
Expand Down
94 changes: 54 additions & 40 deletions apps/web/src/components/chat/RightPanelSurfaceContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);

const PreviewPanel = lazy(() =>
import("../preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
Expand Down Expand Up @@ -128,46 +136,52 @@ export function RightPanelSurfaceContent(props: {
);
}
return (
<PullRequestDetailPanel
getShortcutContext={props.getShortcutContext}
shortcutsEnabled={visible && props.activeSurfaceId === surface.id}
key={`${scopedThreadKey(threadRef)}:${surface.id}`}
environmentId={props.environmentId}
onSelectPullRequest={(reference) => {
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}
/>
<Suspense fallback={<PullRequestDetailGhost />}>
<PullRequestDetailPanel
getShortcutContext={props.getShortcutContext}
shortcutsEnabled={visible && props.activeSurfaceId === surface.id}
key={`${scopedThreadKey(threadRef)}:${surface.id}`}
environmentId={props.environmentId}
onSelectPullRequest={(reference) => {
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}
/>
</Suspense>
);
}
case "pull-requests":
return <ThreadPullRequestsPanel threadRef={threadRef} />;
return (
<Suspense fallback={<PullRequestListGhost />}>
<ThreadPullRequestsPanel threadRef={threadRef} />
</Suspense>
);
case "side-chat":
// Not lazy: revealing a suspended tiptap editor tears down its view and crashes the composer.
return (
Expand Down
151 changes: 82 additions & 69 deletions apps/web/src/components/pullRequest/PullRequestsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,7 +61,6 @@ import {
writePullRequestListPreferences,
} from "./pullRequestListPreferences";
import { environmentMachineIcon } from "../EnvironmentMachineIcon";
import { PullRequestDetailPanel } from "./PullRequestDetailPanel";
import {
PullRequestFiltersMenu,
PullRequestSearchInput,
Expand All @@ -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";
Expand Down Expand Up @@ -142,6 +149,10 @@ const EMPTY_PREVIEW_SESSIONS = {};
const EMPTY_PREVIEW_DESKTOP_STATE = {};
const EMPTY_TERMINAL_LABELS = new Map<string, string>();
const EMPTY_PENDING_SURFACES = new Set<string>();
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";
Expand Down Expand Up @@ -1517,75 +1528,77 @@ export function PullRequestsPage() {
deviceAvailable={false}
pullRequestStatusSeeds={listedPullRequestTabStatuses}
>
<PullRequestDetailPanel
getShortcutContext={getShortcutContext}
shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id}
key={renderedPullRequestSurface.id}
environmentId={panelEnvironmentId}
onGuideShown={onGuideShown}
onSelectPullRequest={(reference) => {
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;
<Suspense fallback={<PullRequestDetailGhost />}>
<PullRequestDetailPanel
getShortcutContext={getShortcutContext}
shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id}
key={renderedPullRequestSurface.id}
environmentId={panelEnvironmentId}
onGuideShown={onGuideShown}
onSelectPullRequest={(reference) => {
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);
}}
/>
</Suspense>
</RightPanelTabs>
) : null}
</div>
Expand Down
Loading