feat: let a plugin panel start folded until the user or the plugin opens it

This commit is contained in:
Linus Rath
2026-10-20 17:21:13 +02:00
parent 7fa207e8e3
commit 95003fd28b
9 changed files with 72 additions and 20 deletions
@@ -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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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']);
@@ -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']);
+19 -7
View File
@@ -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<Record<ComposerSidebarSide, boolean>>(() => ({
// 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<Record<ComposerSidebarSide, boolean | null>>(() => ({
left: readCollapsed("left"),
right: readCollapsed("right"),
}));
const collapsed: Record<ComposerSidebarSide, boolean> = {
left: chosen.left ?? leftOffers.some((o) => o.startFolded),
right: chosen.right ?? rightOffers.some((o) => o.startFolded),
};
const [overlayOpen, setOverlayOpen] = useState<Record<ComposerSidebarSide, boolean>>({ left: false, right: false });
const [widths, setWidths] = useState<Record<ComposerSidebarSide, number>>(() => ({ 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 */ }
};
+12 -5
View File
@@ -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<boolean>(() => {
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<boolean | null>(() => {
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<number>(() => {
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]);
@@ -15,14 +15,14 @@ export function lastSlotProps(slot: string): Record<string, unknown> {
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);
+1 -1
View File
@@ -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 }>;
}
+1 -1
View File
@@ -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:<id>` hook when the
+4 -1
View File
@@ -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<string, ActivePlugin>();
@@ -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 } : {}) });
}
}
}
+4 -2
View File
@@ -43,7 +43,7 @@ import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-securit
// ─── Module-scope state ──────────────────────────────────────
interface PluginExports {
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number; fill?: boolean }>;
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number; fill?: boolean; startFolded?: boolean }>;
hooks?: Record<string, (...args: unknown[]) => unknown>;
/**
* Keyboard shortcut bindings. Each entry's `handler` is registered as a
@@ -913,7 +913,7 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
}
// 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<void> {
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 } : {}),
});
}
}