diff --git a/components/plugins/__tests__/plugin-page-app.test.tsx b/components/plugins/__tests__/plugin-page-app.test.tsx
index f3e394919..cf983810c 100644
--- a/components/plugins/__tests__/plugin-page-app.test.tsx
+++ b/components/plugins/__tests__/plugin-page-app.test.tsx
@@ -28,8 +28,9 @@ const registry = await import('@/lib/plugin-sandbox/registry');
const pages = await import('@/lib/plugin-sandbox/pages');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
+const { isFillableSlot } = await import('@/lib/plugin-sandbox/slot-layout');
-function activate(id: string, slots: string[]) {
+function activate(id: string, slots: string[], fill = false) {
const plugin: InstalledPlugin = {
id, name: 'Kanban', version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions: ['ui:app-page'], grantedPermissions: ['ui:app-page'], entrypoint: 'index.js',
@@ -38,7 +39,7 @@ function activate(id: string, slots: string[]) {
registry.register({
plugin, code: '', tier: 'untrusted',
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
- slotOffers: slots.map((name) => ({ name: name as never, order: 100, hasShouldShow: false })),
+ slotOffers: slots.map((name) => ({ name: name as never, order: 100, hasShouldShow: false, fill })),
hookDisposables: [],
});
}
@@ -67,6 +68,21 @@ describe('plugin pages', () => {
expect(opts.slot).toBe('app-page');
expect(opts.plugin.id).toBe('kanban');
expect(opts.extraProps).toEqual({ pageId: 'board', path: 'cards/42' });
+ expect(createSlotInstance.mock.calls[0][0]).toMatchObject({ fill: false });
+ });
+
+ it('gives an opted-in page the available height through the host and sandbox fill contract', async () => {
+ activate('chat', ['app-page'], true);
+ act(() => pages.setPluginPages('chat', 'Chat', [{ id: 'agent', title: 'Agent' }]));
+ const { container } = render();
+
+ await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
+ expect(createSlotInstance.mock.calls[0][0]).toMatchObject({ slot: 'app-page', fill: true });
+ expect(isFillableSlot('app-page')).toBe(true);
+ expect(isFillableSlot('email-toolbar')).toBe(false);
+ const page = container.querySelector('[data-plugin-page="chat/agent"]');
+ expect(page).toHaveClass('flex', 'flex-col', 'min-h-0', 'overflow-hidden');
+ expect(page?.querySelector('[data-plugin-iframe-fill]')).toHaveStyle({ flex: '1 1 0%', minHeight: 0 });
});
it('passes an empty path for the page itself', async () => {
diff --git a/components/plugins/plugin-page-app.tsx b/components/plugins/plugin-page-app.tsx
index d6f3ad189..e81005e7b 100644
--- a/components/plugins/plugin-page-app.tsx
+++ b/components/plugins/plugin-page-app.tsx
@@ -60,6 +60,11 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
() => offersForSlot("app-page").some((o) => o.pluginId === pluginId),
() => false,
);
+ const fillsPage = useSyncExternalStore(
+ subscribeSlots,
+ () => offersForSlot("app-page").some((o) => o.pluginId === pluginId && o.fill),
+ () => false,
+ );
useDocumentTitle(page?.title ?? null);
useEffect(() => {
@@ -92,8 +97,8 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
let content: React.ReactNode;
if (available) {
content = (
-
-
+
);
} else if (!pluginsReady) {
diff --git a/lib/plugin-sandbox/slot-layout.ts b/lib/plugin-sandbox/slot-layout.ts
index 62b01ebac..d3dedb2e6 100644
--- a/lib/plugin-sandbox/slot-layout.ts
+++ b/lib/plugin-sandbox/slot-layout.ts
@@ -44,7 +44,7 @@ export function clampSlotWidth(width: unknown): number | null {
* can pin controls at the bottom, like a chat input. Elsewhere a frame grows
* with its content.
*/
-export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar'];
+export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-page'];
export function isFillableSlot(slot: string): boolean {
return FILLABLE_SLOTS.includes(slot);