diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 00c9cdd7a..c21f2494e 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -647,6 +647,22 @@ describe('composer-sidebar and composer-sidebar-right', () => { expect(open).toBeNull(); }); + it('start folded when the plugin asks for it, until the user or the plugin opens it', () => { + stubWidth(1400); + offerSlots('p1', ['composer-sidebar-right'], { startFolded: ['composer-sidebar-right'] }); + const { unmount } = render(); + expect(panel('right').dataset.composerSidebarMode).toBe('folded'); + act(() => { requestPanel('p1', 'composer-sidebar-right', 'open', undefined); }); + expect(panel('right').dataset.composerSidebarMode).toBe('docked'); + unmount(); + + // Opened once: it stays open for the next composer. + render(); + expect(panel('right').dataset.composerSidebarMode).toBe('docked'); + fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_hide' })); + expect(panel('right').dataset.composerSidebarMode).toBe('folded'); + }); + it('open a folded panel over the composer when there is no room beside it', () => { stubWidth(600); offerSlots('p1', ['composer-sidebar-right']); diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx index c6952f579..ec53d2b5e 100644 --- a/components/email/__tests__/reader-plugin-surfaces.test.tsx +++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx @@ -163,6 +163,18 @@ describe('email-detail-sidebar', () => { expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull(); }); + it('starts folded when the plugin asks for it, and remembers the user opening it', () => { + stubWidth(1000); + offerSlots('p1', ['email-detail-sidebar'], { startFolded: ['email-detail-sidebar'] }); + const { unmount } = viewer(textMail('Hi')); + expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' })); + expect(screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!.getAttribute('data-plugin-detail-panel')).toBe('docked'); + unmount(); + viewer(textMail('Hi')); + expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument(); + }); + it('docks next to the message when there is room', () => { stubWidth(1000); offerSlots('p1', ['email-detail-sidebar']); diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx index 97e8d3ec2..4f5eacabe 100644 --- a/components/email/composer-plugin-sidebars.tsx +++ b/components/email/composer-plugin-sidebars.tsx @@ -71,9 +71,13 @@ export function composerSidebarModes( const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`; const widthKey = (side: ComposerSidebarSide) => `composerPluginSidebarWidth:${side}`; -function readCollapsed(side: ComposerSidebarSide): boolean { - if (typeof window === "undefined") return false; - try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; } +/** The user's choice for a side; null until they fold or open it once. */ +function readCollapsed(side: ComposerSidebarSide): boolean | null { + if (typeof window === "undefined") return null; + try { + const stored = localStorage.getItem(storageKey(side)); + return stored === "1" ? true : stored === "0" ? false : null; + } catch { return null; } } export function clampSidebarWidth(width: number): number { @@ -94,13 +98,21 @@ function readWidth(side: ComposerSidebarSide): number { * api.ui.openPanel requests for it are answered here. */ export function useComposerSidebars(root: HTMLElement | null, composerId: string | null = null) { - const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0; - const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0; + const leftOffers = usePluginSlotOffers("composer-sidebar"); + const rightOffers = usePluginSlotOffers("composer-sidebar-right"); + const hasLeft = leftOffers.length > 0; + const hasRight = rightOffers.length > 0; const [width, setWidth] = useState(0); - const [collapsed, setCollapsed] = useState>(() => ({ + // The user's choice per side; until they make one, a panel whose plugin + // asked to start folded (a chat opened on demand) is folded, the rest open. + const [chosen, setChosen] = useState>(() => ({ left: readCollapsed("left"), right: readCollapsed("right"), })); + const collapsed: Record = { + left: chosen.left ?? leftOffers.some((o) => o.startFolded), + right: chosen.right ?? rightOffers.some((o) => o.startFolded), + }; const [overlayOpen, setOverlayOpen] = useState>({ left: false, right: false }); const [widths, setWidths] = useState>(() => ({ left: readWidth("left"), right: readWidth("right") })); @@ -127,7 +139,7 @@ export function useComposerSidebars(root: HTMLElement | null, composerId: string }; const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => { - setCollapsed((prev) => ({ ...prev, [side]: value })); + setChosen((prev) => ({ ...prev, [side]: value })); try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ } }; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index efb8d230a..51664aca0 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1531,10 +1531,16 @@ export function EmailViewer({ emailIdRef.current = email?.id; const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]); const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]); - const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(() => { - if (typeof window === 'undefined') return false; - try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; } + // The user's choice; until they make one, a panel whose plugin asked to + // start folded (a chat opened on demand) is folded, the rest open. + const [detailSidebarChoice, setDetailSidebarCollapsed] = useState(() => { + if (typeof window === 'undefined') return null; + try { + const stored = localStorage.getItem('emailDetailSidebarCollapsed'); + return stored === '1' ? true : stored === '0' ? false : null; + } catch { return null; } }); + const detailSidebarCollapsed = detailSidebarChoice ?? detailSlots.some((o) => o.startFolded); const [detailSidebarWidth, setDetailSidebarWidth] = useState(() => { if (typeof window === 'undefined') return 280; try { @@ -1588,8 +1594,9 @@ export function EmailViewer({ }), []); useEffect(() => { - try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ } - }, [detailSidebarCollapsed]); + if (detailSidebarChoice === null) return; + try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarChoice ? '1' : '0'); } catch { /* ignore */ } + }, [detailSidebarChoice]); useEffect(() => { try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ } }, [detailSidebarWidth]); diff --git a/components/plugins/__tests__/fake-plugin-offer.ts b/components/plugins/__tests__/fake-plugin-offer.ts index f4be0b2d2..a7a065f71 100644 --- a/components/plugins/__tests__/fake-plugin-offer.ts +++ b/components/plugins/__tests__/fake-plugin-offer.ts @@ -15,14 +15,14 @@ export function lastSlotProps(slot: string): Record { return lastProps.get(slot) ?? {}; } -/** Registers a fake plugin that offers the given slots; `fill` names the panel slots whose frame fills the panel. */ -export function offerSlots(pluginId: string, slots: string[], { fill = [] }: { fill?: string[] } = {}): void { +/** Registers a fake plugin that offers the given slots; `fill` names the panel slots whose frame fills the panel, `startFolded` those that start folded. */ +export function offerSlots(pluginId: string, slots: string[], { fill = [], startFolded = [] }: { fill?: string[]; startFolded?: string[] } = {}): void { register({ plugin: { id: pluginId, name: pluginId }, code: '', tier: 'untrusted', background: {}, - slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false, ...(fill.includes(name) ? { fill: true } : {}) })), + slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false, ...(fill.includes(name) ? { fill: true } : {}), ...(startFolded.includes(name) ? { startFolded: true } : {}) })), hookDisposables: [], } as unknown as ActivePlugin); offered.add(pluginId); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 3d7bd0bad..d46c0b878 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -172,7 +172,7 @@ export interface SlotOptions { export interface InitDoneInfo { hooks: string[]; - slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean }>; + slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }>; shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>; } diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 46a825678..101ff3d7a 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -92,7 +92,7 @@ export interface InitDoneMsg { /** Hook names the plugin registered. The host installs proxy handlers. */ hooks: string[]; /** Slots the plugin claims. Used by the host to know when a slot is offered. `fill`: a panel slot that fills its panel (see slot-layout.ts). */ - slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean }>; + slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }>; /** * Keyboard shortcuts the plugin declares. The host installs a global * keydown listener that dispatches to the `shortcut:` hook when the diff --git a/lib/plugin-sandbox/registry.ts b/lib/plugin-sandbox/registry.ts index fb5a83115..8549970c8 100644 --- a/lib/plugin-sandbox/registry.ts +++ b/lib/plugin-sandbox/registry.ts @@ -15,6 +15,8 @@ export interface SlotOffer { hasShouldShow: boolean; /** The frame fills its panel (panel slots only; see slot-layout.ts). */ fill?: boolean; + /** The panel starts folded until the user or the plugin opens it (panel slots only). */ + startFolded?: boolean; } export interface ActivePlugin { @@ -34,6 +36,7 @@ export interface ResolvedSlotOffer { order: number; hasShouldShow: boolean; fill?: boolean; + startFolded?: boolean; } const active = new Map(); @@ -83,7 +86,7 @@ export function offersForSlot(slot: SlotName): readonly ResolvedSlotOffer[] { for (const entry of active.values()) { for (const offer of entry.slotOffers) { if (offer.name === slot) { - out.push({ pluginId: entry.plugin.id, order: offer.order, hasShouldShow: offer.hasShouldShow, ...(offer.fill ? { fill: true } : {}) }); + out.push({ pluginId: entry.plugin.id, order: offer.order, hasShouldShow: offer.hasShouldShow, ...(offer.fill ? { fill: true } : {}), ...(offer.startFolded ? { startFolded: true } : {}) }); } } } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index f3e15caa9..10fa7876f 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -43,7 +43,7 @@ import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-securit // ─── Module-scope state ────────────────────────────────────── interface PluginExports { - slots?: Record>; shouldShow?: (ctx: unknown) => boolean; order?: number; fill?: boolean }>; + slots?: Record>; shouldShow?: (ctx: unknown) => boolean; order?: number; fill?: boolean; startFolded?: boolean }>; hooks?: Record unknown>; /** * Keyboard shortcut bindings. Each entry's `handler` is registered as a @@ -913,7 +913,7 @@ async function bootBackground(payload: BackgroundInit): Promise { } // Enumerate slot offers. - const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean }> = []; + const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }> = []; const slots = exports.slots ?? {}; for (const [name, def] of Object.entries(slots)) { if (def && typeof def.component === 'function') { @@ -923,6 +923,8 @@ async function bootBackground(payload: BackgroundInit): Promise { order: typeof def.order === 'number' ? def.order : 100, // A panel that fills its panel instead of growing with its content. ...(def.fill === true && isFillableSlot(name) ? { fill: true } : {}), + // A panel the user opens when they want it (a chat), not on every message. + ...(def.startFolded === true && isFillableSlot(name) ? { startFolded: true } : {}), }); } }