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 (
))}