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 } : {}),
});
}
}