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) {...@@ -1884,7 +1884,7 @@ function ChatViewContent(props: ChatViewProps) {
1884 panelAnimationDurationMs,1884 panelAnimationDurationMs,
1885 );1885 );
1886 const rightPanelPresent = rightPanelPresence.present;1886 const rightPanelPresent = rightPanelPresence.present;
1887 const rightPanelControlsInPanel = rightPanelPresent && rightPanelOpen;1887 const rightPanelControlsInPanel = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen;
1888 const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;1888 const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;
1889 const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];1889 const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
1890 const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer(1890 const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer(
...@@ -7526,15 +7526,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7526,15 +7526,6 @@ function ChatViewContent(props: ChatViewProps) {
7526 <div className="pointer-events-auto flex h-full items-center">{panelToggleControls}</div>7526 <div className="pointer-events-auto flex h-full items-center">{panelToggleControls}</div>
7527 </div>7527 </div>
7528 );7528 );
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 );
7538 const rightPanelContent = activeThreadRef ? (7529 const rightPanelContent = activeThreadRef ? (
7539 renderedRightPanelSurface?.kind === "preview" ? (7530 renderedRightPanelSurface?.kind === "preview" ? (
7540 <Suspense fallback={null}>7531 <Suspense fallback={null}>
...@@ -7681,6 +7672,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7681,6 +7672,7 @@ function ChatViewContent(props: ChatViewProps) {
76817672
7682 return (7673 return (
7683 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">7674 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
7675 {!rightPanelControlsInPanel ? panelLayoutControls : null}
7684 <div7676 <div
7685 className={cn(7677 className={cn(
7686 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",7678 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",
...@@ -7695,7 +7687,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7695,7 +7687,6 @@ function ChatViewContent(props: ChatViewProps) {
7695 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}7687 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}
7696 className="relative bg-background"7688 className="relative bg-background"
7697 >7689 >
7698 {!rightPanelControlsInPanel ? panelLayoutControls : null}
7699 <ChatHeader7690 <ChatHeader
7700 {...(!supportsPullRequests || activeProjectRepository === null7691 {...(!supportsPullRequests || activeProjectRepository === null
7701 ? {}7692 ? {}
...@@ -8126,7 +8117,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -8126,7 +8117,6 @@ function ChatViewContent(props: ChatViewProps) {
8126 mode="inline"8117 mode="inline"
8127 open={rightPanelOpen}8118 open={rightPanelOpen}
8128 maximized={rightPanelMaximized}8119 maximized={rightPanelMaximized}
8129 layoutControls={rightPanelOpen ? inlineRightPanelControls : null}
8130 surfaces={renderedRightPanelSurfaces}8120 surfaces={renderedRightPanelSurfaces}
8131 environmentId={activeThreadRef.environmentId}8121 environmentId={activeThreadRef.environmentId}
8132 activeSurfaceId={renderedRightPanelSurface?.id ?? null}8122 activeSurfaceId={renderedRightPanelSurface?.id ?? null}
apps/web/src/components/preview/PreviewPanelShell.tsx+37-16
...@@ -68,10 +68,11 @@ export function PreviewPanelShell(props: {...@@ -68,10 +68,11 @@ export function PreviewPanelShell(props: {
68 const isInline = props.mode === "inline";68 const isInline = props.mode === "inline";
69 const collapsible = isInline && props.open !== undefined;69 const collapsible = isInline && props.open !== undefined;
70 const open = props.open ?? true;70 const open = props.open ?? true;
71 const maximized = props.maximized ?? false;
71 const hostRef = useRef<HTMLDivElement | null>(null);72 const hostRef = useRef<HTMLDivElement | null>(null);
72 // Only inline non-maximized mode applies `width`/`maxWidth`; skip the73 // Only inline non-maximized mode applies `width`/`maxWidth`; skip the
73 // container measurement (and its re-renders) everywhere else.74 // container measurement (and its re-renders) everywhere else.
74 const maxWidth = useClampedMaxWidth(hostRef, isInline && !props.maximized);75 const maxWidth = useClampedMaxWidth(hostRef, isInline && !maximized);
75 const { width, handlers } = useResizableWidth({76 const { width, handlers } = useResizableWidth({
76 storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY,77 storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY,
77 defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH,78 defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH,
...@@ -79,33 +80,50 @@ export function PreviewPanelShell(props: {...@@ -79,33 +80,50 @@ export function PreviewPanelShell(props: {
79 maxWidth,80 maxWidth,
80 edge: "left",81 edge: "left",
81 });82 });
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;
83 useLayoutEffect(() => {107 useLayoutEffect(() => {
84 const previous = previousLayoutRef.current;108 if (!suppressWidthTransition) return;
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");
90 let restoreFrame = 0;109 let restoreFrame = 0;
91 const paintFrame = window.requestAnimationFrame(() => {110 const paintFrame = window.requestAnimationFrame(() => {
92 restoreFrame = window.requestAnimationFrame(() => {111 restoreFrame = window.requestAnimationFrame(() => {
93 host.style.removeProperty("transition-duration");112 setLayoutTransition((current) => ({ ...current, suppressed: false }));
94 });113 });
95 });114 });
96 return () => {115 return () => {
97 window.cancelAnimationFrame(paintFrame);116 window.cancelAnimationFrame(paintFrame);
98 window.cancelAnimationFrame(restoreFrame);117 window.cancelAnimationFrame(restoreFrame);
99 host.style.removeProperty("transition-duration");
100 };118 };
101 }, [collapsible, open, width]);119 }, [suppressWidthTransition]);
102 return (120 return (
103 <div121 <div
104 ref={hostRef}122 ref={hostRef}
105 className={cn(123 className={cn(
106 "relative flex h-full min-h-0 min-w-0 max-w-full flex-col self-stretch bg-background",124 "relative flex h-full min-h-0 min-w-0 max-w-full flex-col self-stretch bg-background",
107 isInline125 isInline
108 ? props.maximized126 ? maximized
109 ? "flex-1 border-l border-border"127 ? "flex-1 border-l border-border"
110 : "shrink-0 border-l border-border"128 : "shrink-0 border-l border-border"
111 : "w-full",129 : "w-full",
...@@ -116,17 +134,20 @@ export function PreviewPanelShell(props: {...@@ -116,17 +134,20 @@ export function PreviewPanelShell(props: {
116 )}134 )}
117 style={135 style={
118 isInline136 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 }
120 : undefined141 : undefined
121 }142 }
122 data-preview-panel-mode={props.mode}143 data-preview-panel-mode={props.mode}
123 data-preview-panel-maximized={props.maximized ? "true" : "false"}144 data-preview-panel-maximized={maximized ? "true" : "false"}
124 >145 >
125 {isInline && !props.maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}146 {isInline && !maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}
126 <div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}>147 <div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}>
127 <div148 <div
128 className="flex h-full min-h-0 min-w-0 flex-col"149 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}
130 >151 >
131 {useDragRegion ? <div className="electron-drag-region h-0 w-full" aria-hidden /> : null}152 {useDragRegion ? <div className="electron-drag-region h-0 w-full" aria-hidden /> : null}
132 {props.children}153 {props.children}