From b9dedcdafb1e8c01a36e1eeb15dc27f1aac879cc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:26:04 +0100 Subject: [PATCH] fix: keep the assistant's chat usable in a short side panel it shares with another plugin --- .../plugin-iframe-slot-props.test.tsx | 7 +++++ .../__tests__/plugin-slot-sharing.test.tsx | 27 +++++++++++++++++++ components/plugins/plugin-iframe-slot.tsx | 6 +++-- components/plugins/plugin-slot.tsx | 9 ++++++- 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 components/plugins/__tests__/plugin-slot-sharing.test.tsx diff --git a/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx b/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx index 64c9639d0..eb57628f3 100644 --- a/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx +++ b/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx @@ -52,6 +52,13 @@ describe('PluginIframeSlot', () => { expect(wrapper.style.flex).toBe('1 1 0%'); }); + it('keeps a filling frame at its minimum height when it is given one', async () => { + activePlugin = plugin(['ui:composer-sidebar']); + const { container } = render(); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + expect((container.querySelector('[data-plugin-iframe-slot]') as HTMLElement).style.minHeight).toBe('280px'); + }); + it('mounts a banner-only plugin without the opened message', async () => { activePlugin = plugin(['ui:email-banner']); render(); diff --git a/components/plugins/__tests__/plugin-slot-sharing.test.tsx b/components/plugins/__tests__/plugin-slot-sharing.test.tsx new file mode 100644 index 000000000..a55ba54a2 --- /dev/null +++ b/components/plugins/__tests__/plugin-slot-sharing.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { PluginSlot, SHARED_FILL_MIN_HEIGHT } from '../plugin-slot'; + +let offers: Array<{ pluginId: string; fill?: boolean }> = []; +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => offers })); +vi.mock('../plugin-iframe-slot', () => ({ + PluginIframeSlot: ({ pluginId, minFillHeight }: { pluginId: string; minFillHeight?: number }) => ( +
+ ), +})); + +describe('PluginSlot', () => { + it('keeps a filling frame usable next to another frame in a short panel', () => { + offers = [{ pluginId: 'notes' }, { pluginId: 'chat', fill: true }]; + render(); + expect(screen.getByTestId('chat').dataset.min).toBe(String(SHARED_FILL_MIN_HEIGHT)); + expect(screen.getByTestId('notes').dataset.min).toBe(''); + }); + + it('lets a filling frame alone in its panel take whatever height there is', () => { + offers = [{ pluginId: 'chat', fill: true }]; + render(); + expect(screen.getByTestId('chat').dataset.min).toBe(''); + }); +}); diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index 1865ce624..a5470318b 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -25,6 +25,8 @@ interface Props { extraProps?: Record; /** The frame fills its panel and scrolls inside, instead of growing with its content. */ fill?: boolean; + /** With `fill`: the height the frame keeps however little room its panel has. */ + minFillHeight?: number; /** * Told whether the frame is up: false when the plugin's shouldShow says no * or its frame fails to start, true once it has started. For a slot that @@ -45,7 +47,7 @@ interface FloatingDropdownState { ariaLabel?: string; } -export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false, onShownChange }: Props) { +export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false, minFillHeight, onShownChange }: Props) { const wrapperRef = useRef(null); const instanceRef = useRef(null); const onShownChangeRef = useRef(onShownChange); @@ -189,7 +191,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false, onS ref={wrapperRef} style={inline ? { display: 'inline-block', verticalAlign: 'middle', height, width: width ?? 0, maxWidth: '100%', overflow: 'hidden' } - : fill ? { flex: '1 1 0%', minHeight: 0 } : { height, minHeight: height }} + : fill ? { flex: '1 1 0%', minHeight: minFillHeight ?? 0 } : { height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} data-plugin-iframe-fill={fill || undefined} /> diff --git a/components/plugins/plugin-slot.tsx b/components/plugins/plugin-slot.tsx index d46b9640d..650850f78 100644 --- a/components/plugins/plugin-slot.tsx +++ b/components/plugins/plugin-slot.tsx @@ -13,6 +13,9 @@ interface PluginSlotProps { extraProps?: Record; } +/** Height a filling frame keeps when it shares its panel with other frames. */ +export const SHARED_FILL_MIN_HEIGHT = 280; + export function PluginSlot({ name, className, extraProps }: PluginSlotProps) { // Without a retired plugin's offer while Bulwark does its job (host-features.ts). const offers = usePluginSlotOffers(name); @@ -21,8 +24,11 @@ export function PluginSlot({ name, className, extraProps }: PluginSlotProps) { // Inline slots line up next to each other in a toolbar row. A panel slot // with a frame that fills the panel takes the panel's height and shares - // it between such frames. + // it between such frames. Next to another frame it keeps a usable height + // however short the panel is: the panel scrolls instead of squeezing it + // away, input and all. const fills = offers.some((offer) => offer.fill); + const minFillHeight = offers.length > 1 ? SHARED_FILL_MIN_HEIGHT : undefined; return (
))}