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);