authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 23:17:41-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 23:17:41-04:00
log42bdea1c9c1d4b7c5c2e77cd23cf53fae68a6fd4
treec4bb33ab3d4938ef9cf72c2fed234d3e91a2428b
parent95390ed78458f139cb795bab4baa53ec39222ff7
signature Signed by PGP key B5690EEEBB952194

fix(web): stabilize right panel transitions (#9554)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 39 insertions(+), 28 deletions(-)

apps/web/src/components/ChatView.tsx+2-12
......@@ -1884,7 +1884,7 @@ function ChatViewContent(props: ChatViewProps) {
18841884 panelAnimationDurationMs,
18851885 );
18861886 const rightPanelPresent = rightPanelPresence.present;
1887 const rightPanelControlsInPanel = rightPanelPresent && rightPanelOpen;
1887 const rightPanelControlsInPanel = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen;
18881888 const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;
18891889 const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
18901890 const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer(
......@@ -7526,15 +7526,6 @@ function ChatViewContent(props: ChatViewProps) {
75267526 <div className="pointer-events-auto flex h-full items-center">{panelToggleControls}</div>
75277527 </div>
75287528 );
7529 const inlineRightPanelControls = (
7530 <div className="mr-px flex h-full items-center gap-1 [-webkit-app-region:no-drag]">
7531 <RightPanelMaximizeControl
7532 maximized={rightPanelMaximized}
7533 onToggle={toggleRightPanelMaximized}
7534 />
7535 {panelToggleControls}
7536 </div>
7537 );
75387529 const rightPanelContent = activeThreadRef ? (
75397530 renderedRightPanelSurface?.kind === "preview" ? (
75407531 <Suspense fallback={null}>
......@@ -7681,6 +7672,7 @@ function ChatViewContent(props: ChatViewProps) {
76817672
76827673 return (
76837674 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
7675 {!rightPanelControlsInPanel ? panelLayoutControls : null}
76847676 <div
76857677 className={cn(
76867678 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",
......@@ -7695,7 +7687,6 @@ function ChatViewContent(props: ChatViewProps) {
76957687 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}
76967688 className="relative bg-background"
76977689 >
7698 {!rightPanelControlsInPanel ? panelLayoutControls : null}
76997690 <ChatHeader
77007691 {...(!supportsPullRequests || activeProjectRepository === null
77017692 ? {}
......@@ -8126,7 +8117,6 @@ function ChatViewContent(props: ChatViewProps) {
81268117 mode="inline"
81278118 open={rightPanelOpen}
81288119 maximized={rightPanelMaximized}
8129 layoutControls={rightPanelOpen ? inlineRightPanelControls : null}
81308120 surfaces={renderedRightPanelSurfaces}
81318121 environmentId={activeThreadRef.environmentId}
81328122 activeSurfaceId={renderedRightPanelSurface?.id ?? null}
apps/web/src/components/preview/PreviewPanelShell.tsx+37-16
......@@ -68,10 +68,11 @@ export function PreviewPanelShell(props: {
6868 const isInline = props.mode === "inline";
6969 const collapsible = isInline && props.open !== undefined;
7070 const open = props.open ?? true;
71 const maximized = props.maximized ?? false;
7172 const hostRef = useRef<HTMLDivElement | null>(null);
7273 // Only inline non-maximized mode applies `width`/`maxWidth`; skip the
7374 // container measurement (and its re-renders) everywhere else.
74 const maxWidth = useClampedMaxWidth(hostRef, isInline && !props.maximized);
75 const maxWidth = useClampedMaxWidth(hostRef, isInline && !maximized);
7576 const { width, handlers } = useResizableWidth({
7677 storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY,
7778 defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH,
......@@ -79,33 +80,50 @@ export function PreviewPanelShell(props: {
7980 maxWidth,
8081 edge: "left",
8182 });
82 const previousLayoutRef = useRef({ open, width });
83 // Derive suppression before the layout commits so the browser never creates
84 // a width transition for resize or maximize changes.
85 const [layoutTransition, setLayoutTransition] = useState(() => ({
86 open,
87 width,
88 maximized,
89 suppressed: false,
90 }));
91 if (
92 layoutTransition.open !== open ||
93 layoutTransition.width !== width ||
94 layoutTransition.maximized !== maximized
95 ) {
96 setLayoutTransition({
97 open,
98 width,
99 maximized,
100 suppressed:
101 collapsible &&
102 layoutTransition.open === open &&
103 (layoutTransition.width !== width || layoutTransition.maximized !== maximized),
104 });
105 }
106 const suppressWidthTransition = layoutTransition.suppressed;
83107 useLayoutEffect(() => {
84 const previous = previousLayoutRef.current;
85 previousLayoutRef.current = { open, width };
86 if (!collapsible || previous.open !== open || previous.width === width) return;
87 const host = hostRef.current;
88 if (!host?.closest("[data-panel-animations=true]")) return;
89 host.style.setProperty("transition-duration", "0ms");
108 if (!suppressWidthTransition) return;
90109 let restoreFrame = 0;
91110 const paintFrame = window.requestAnimationFrame(() => {
92111 restoreFrame = window.requestAnimationFrame(() => {
93 host.style.removeProperty("transition-duration");
112 setLayoutTransition((current) => ({ ...current, suppressed: false }));
94113 });
95114 });
96115 return () => {
97116 window.cancelAnimationFrame(paintFrame);
98117 window.cancelAnimationFrame(restoreFrame);
99 host.style.removeProperty("transition-duration");
100118 };
101 }, [collapsible, open, width]);
119 }, [suppressWidthTransition]);
102120 return (
103121 <div
104122 ref={hostRef}
105123 className={cn(
106124 "relative flex h-full min-h-0 min-w-0 max-w-full flex-col self-stretch bg-background",
107125 isInline
108 ? props.maximized
126 ? maximized
109127 ? "flex-1 border-l border-border"
110128 : "shrink-0 border-l border-border"
111129 : "w-full",
......@@ -116,17 +134,20 @@ export function PreviewPanelShell(props: {
116134 )}
117135 style={
118136 isInline
119 ? { width: props.maximized ? "100%" : collapsible && !open ? "0px" : `${width}px` }
137 ? {
138 width: maximized ? "100%" : collapsible && !open ? "0px" : `${width}px`,
139 transitionDuration: suppressWidthTransition ? "0ms" : undefined,
140 }
120141 : undefined
121142 }
122143 data-preview-panel-mode={props.mode}
123 data-preview-panel-maximized={props.maximized ? "true" : "false"}
144 data-preview-panel-maximized={maximized ? "true" : "false"}
124145 >
125 {isInline && !props.maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}
146 {isInline && !maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}
126147 <div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}>
127148 <div
128149 className="flex h-full min-h-0 min-w-0 flex-col"
129 style={collapsible && !props.maximized ? { width: `calc(${width}px - 1px)` } : undefined}
150 style={collapsible && !maximized ? { width: `calc(${width}px - 1px)` } : undefined}
130151 >
131152 {useDragRegion ? <div className="electron-drag-region h-0 w-full" aria-hidden /> : null}
132153 {props.children}