Merge branch 'feat/assistant-everywhere' into feat/assistant-b

This commit is contained in:
Linus Rath
2026-11-03 00:24:50 +01:00
64 changed files with 1878 additions and 71 deletions
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { slotPropsFor, mayOfferSlot } from '@/lib/plugin-sandbox/permissions';
import { PANEL_SLOTS, onPanelRequest, requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
import { isFillableSlot } from '@/lib/plugin-sandbox/slot-layout';
import { ALL_PERMISSIONS, type InstalledPlugin, type Permission } from '@/lib/plugin-types';
import { slotSpec } from '@/lib/plugin-slot-catalog';
import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
import { runPluginAction } from '@/lib/plugin-sandbox/actions';
import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
import { APP_SIDE_PANEL_CONTEXT_FIELDS } from '@/lib/plugin-sandbox/assistant/f.types';
// The app-side-panel slot (area f of the assistant's host APIs): a side
// panel in Calendar, Contacts, Files and Settings. Its context carries the
// open event, contact, files or settings page, each behind the permission
// that guards that data elsewhere.
const plugin = (permissions: Permission[]): InstalledPlugin => ({
id: 'p', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions, grantedPermissions: permissions, entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
});
const context = {
event: { id: 'e1', title: 'Team offsite' },
contact: { id: 'c1', fullName: 'Aa Bob' },
selectedCount: 3,
files: [{ id: 'f1', name: 'agenda.pdf' }],
fileCount: 1,
folder: { id: 'd1', path: '/Projects' },
page: { id: 'reading', label: 'Reading' },
secret: 'not in the catalogue',
};
afterEach(() => resetPanelRequests());
describe('app-side-panel', () => {
it('is a slot of its own permission, fillable, and a panel plugins can open', () => {
expect(ALL_PERMISSIONS).toContain('ui:side-panel');
expect(slotSpec('app-side-panel')?.permissions).toEqual(['ui:side-panel']);
expect(mayOfferSlot(plugin(['ui:side-panel']), 'app-side-panel')).toBe(true);
expect(mayOfferSlot(plugin(['ui:email-details']), 'app-side-panel')).toBe(false);
expect(isFillableSlot('app-side-panel')).toBe(true);
expect(PANEL_SLOTS).toContain('app-side-panel');
expect(assistantFeatures().sidePanel).toBe(true);
});
it('gives the plugin only the parts of the context it has permissions for', () => {
const props = { view: 'calendar', context, focusRequest: 2 };
// ui:side-panel alone: the view and the settings page, nothing of the user's data.
expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', props)).toEqual({
view: 'calendar', context: { page: context.page }, focusRequest: 2,
});
expect(slotPropsFor(plugin(['ui:side-panel', 'calendar:read']), 'app-side-panel', props).context).toEqual({
event: context.event, page: context.page,
});
expect(slotPropsFor(plugin(['ui:side-panel', 'contacts:read']), 'app-side-panel', props).context).toEqual({
contact: context.contact, selectedCount: 3, page: context.page,
});
expect(slotPropsFor(plugin(['ui:side-panel', 'files:read']), 'app-side-panel', props).context).toEqual({
files: context.files, fileCount: 1, folder: context.folder, page: context.page,
});
// A part the catalogue doesn't name never goes out, whatever the plugin may do.
const all = slotPropsFor(plugin(['ui:side-panel', 'calendar:read', 'contacts:read', 'files:read']), 'app-side-panel', props);
expect(all.context).not.toHaveProperty('secret');
expect(Object.keys(all.context as object).sort()).toEqual(Object.keys(APP_SIDE_PANEL_CONTEXT_FIELDS).sort());
});
it("cuts the context the same way in the click that asks the plugin for its dialog (the loader's dispatcher)", async () => {
// Importing the loader installs its action dispatcher, as in the app.
await import('@/lib/plugin-sandbox/loader');
const invokeHook = vi.fn(async () => undefined);
register({ plugin: plugin(['ui:side-panel', 'calendar:read']), code: '', tier: 'untrusted', background: { invokeHook }, slotOffers: [], hookDisposables: [] } as unknown as ActivePlugin);
try {
await runPluginAction('p', 'app-side-panel', 'open', { view: 'contacts', context: { contact: context.contact, page: context.page, secret: 'x' } });
expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'app-side-panel', actionId: 'open', context: { view: 'contacts', context: { page: context.page } } }]);
} finally {
deregister('p');
}
// Garbage in the context is dropped, not passed on.
expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', { context: 'x' }).context).toEqual({});
// The loader's import is slow under load.
}, 30_000);
it('tells each plugin whether it may show the panel (sidePanel follows its grant)', () => {
expect(assistantFeatures(plugin(['ui:side-panel'])).sidePanel).toBe(true);
// Enabled before an update added the permission: not granted yet.
const notGranted = { ...plugin(['ui:side-panel']), grantedPermissions: [] };
expect(assistantFeatures(notGranted).sidePanel).toBe(false);
expect(assistantFeatures(plugin([])).sidePanel).toBe(false);
});
it('routes api.ui.openPanel for it to the view that shows it, and nowhere else', () => {
expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBeNull();
const handler = vi.fn((request: { slot: string }) => (request.slot === 'app-side-panel' ? true : null));
onPanelRequest(handler);
expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBe(true);
expect(handler).toHaveBeenCalledWith({ pluginId: 'p', slot: 'app-side-panel', mode: 'open' });
expect(requestPanel('p', 'email-detail-sidebar', 'open', undefined)).toBeNull();
});
});
+11 -6
View File
@@ -19,8 +19,8 @@ import {
} from '@/lib/plugin-sandbox/host-features';
import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top; their own tests check them. */
const withAssistant = (core: Record<string, boolean>) => ({ ...core, ...assistantFeatures() });
/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top, as `plugin` sees them; their own tests check them. */
const withAssistant = (core: Record<string, boolean>, plugin?: never) => ({ ...core, ...assistantFeatures(plugin) });
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
@@ -85,21 +85,21 @@ describe('host bridge', () => {
const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never;
const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' });
const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit;
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }));
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin));
// Before init has gone out a change lands in the payload ...
setExternalMail({});
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }));
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin));
// ... after it, as a host-change message.
const sent: unknown[] = [];
(instance as unknown as { pendingInit: unknown }).pendingInit = null;
(instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m);
setExternalMail({ recipientWarning: true });
expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }) }]);
expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }, plugin) }]);
// The admin turns third-party traffic off: running plugins hear it.
setNoThirdPartyTraffic(true);
expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }) });
expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }, plugin) });
setNoThirdPartyTraffic(false);
instance.destroy();
}, 30_000);
@@ -172,5 +172,10 @@ describe('api.host in the sandbox runtime', () => {
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(true);
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true, sidePanel: 'yes' } });
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(false);
// A flag the host no longer names turns off instead of keeping its old value.
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true, sidePanel: true } });
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(true);
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true } });
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(false);
}, 30_000);
});
+14 -3
View File
@@ -1,6 +1,11 @@
// Area f of the assistant's host APIs, host side (see README.md).
//
// Area f adds no methods: the app-side-panel slot (plugin-slot-catalog.ts,
// gated by ui:side-panel) and its panel requests (panel-requests.ts) are
// the whole host side, mounted by components/plugins/app-side-panel.tsx.
import type { InstalledPlugin, Permission } from '../../plugin-types';
import { mayOfferSlot } from '../permissions';
import { NOT_MINE } from './not-mine';
import type { AreaHostFeaturesF } from './f.types';
@@ -12,7 +17,13 @@ export async function dispatch(_plugin: InstalledPlugin, _method: string, _args:
return NOT_MINE;
}
/** This area's flags in api.host.features, right now. */
export function features(): AreaHostFeaturesF {
return {};
/**
* This area's flags in api.host.features, right now. For one plugin,
* sidePanel says whether it may show the panel (ui:side-panel granted; a
* plugin enabled before an update that added it lacks it until turned off
* and on), so it can tell the user instead of offering a switch that does
* nothing.
*/
export function features(plugin?: InstalledPlugin): AreaHostFeaturesF {
return { sidePanel: plugin ? mayOfferSlot(plugin, 'app-side-panel') : true };
}
+63 -3
View File
@@ -1,8 +1,68 @@
// Area f of the assistant's host APIs: its permissions and its flags in
// api.host.features (see README.md). Pure: no imports from the app.
// api.host.features (see README.md). Pure: no imports from the app (the
// view types below are type-only).
import type { CalendarEventView, ContactView, FileResourceView } from '../../plugin-types';
/** Permissions this area adds to ALL_PERMISSIONS. */
export const PERMISSIONS = [] as const;
export const PERMISSIONS = ['ui:side-panel'] as const;
/** Flags this area adds to api.host.features. */
export interface AreaHostFeaturesF {}
export interface AreaHostFeaturesF {
/**
* The app-side-panel slot: a side panel in Calendar, Contacts, Files and
* Settings, which api.ui.openPanel / closePanel / togglePanel answer for.
* Missing (false) on webmails before it.
*/
sidePanel?: boolean;
}
/** The views that show the app-side-panel. Mail has the reader's panel (email-detail-sidebar). */
export const APP_SIDE_PANEL_VIEWS = ['calendar', 'contacts', 'files', 'settings'] as const;
export type AppSidePanelViewName = typeof APP_SIDE_PANEL_VIEWS[number];
/** The most selected files the panel names; more are only counted. */
export const MAX_SIDE_PANEL_FILES = 20;
/**
* What the view shows, in the app-side-panel's `context` prop. Each field
* reaches a plugin only with its permission (APP_SIDE_PANEL_CONTEXT_FIELDS):
* the event with calendar:read, the contact and the selection count with
* contacts:read, files and folder with files:read; the settings page with
* none.
*/
export interface AppSidePanelContext {
/** Calendar: the event open in the details or the editor. */
event?: CalendarEventView;
/** Contacts: the contact open in the details. */
contact?: ContactView;
/** Contacts: how many contacts are ticked in the list. */
selectedCount?: number;
/** Files: the selected files (at most 20). */
files?: FileResourceView[];
/** Files: how many files are selected, also past the 20 in `files`. */
fileCount?: number;
/** Files: the open folder. */
folder?: { id: string | null; path: string };
/** Settings: the open page, by its id and its name in the user's language. */
page?: { id: string; label: string };
}
/** The app-side-panel's props. */
export interface AppSidePanelProps {
view: AppSidePanelViewName;
context: AppSidePanelContext;
/** Counts up when the panel opens on request (the toggle, Alt+A, api.ui.openPanel). */
focusRequest: number;
}
/** Which permission each field of the app-side-panel's `context` needs (null: none). */
export const APP_SIDE_PANEL_CONTEXT_FIELDS = {
event: 'calendar:read',
contact: 'contacts:read',
selectedCount: 'contacts:read',
files: 'files:read',
fileCount: 'files:read',
folder: 'files:read',
page: null,
} as const satisfies Record<keyof AppSidePanelContext, string | null>;
+6 -3
View File
@@ -23,7 +23,10 @@ export async function dispatchAssistant(plugin: InstalledPlugin, method: string,
return NOT_MINE;
}
/** Every area's api.host.features flags, right now. */
export function assistantFeatures(): AssistantHostFeatures {
return Object.assign({}, ...AREAS.map((a) => a.features()));
/**
* Every area's api.host.features flags, right now; with `plugin`, as that
* plugin's frames see them (a flag may depend on what it was granted).
*/
export function assistantFeatures(plugin?: InstalledPlugin): AssistantHostFeatures {
return Object.assign({}, ...AREAS.map((a) => (a.features as (p?: InstalledPlugin) => object)(plugin)));
}
+2 -1
View File
@@ -2198,7 +2198,7 @@ export async function dispatchApiCall(
// real app-root overlay, instead of a fixed confirm/prompt form - see
// the 'plugin-dialog' SlotName / host-dialog.ts comments for why this
// exists (a small toolbar/row slot can't show a large custom UI).
const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record<string, unknown>; width?: number };
const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record<string, unknown>; width?: number; fill?: boolean };
if (!opts.slot || typeof opts.slot !== 'string') {
throw new Error('ui.openDialog requires a "slot" name');
}
@@ -2209,6 +2209,7 @@ export async function dispatchApiCall(
slot: opts.slot,
extraProps: (opts.extraProps && typeof opts.extraProps === 'object') ? opts.extraProps : {},
width: typeof opts.width === 'number' ? opts.width : undefined,
...(opts.fill === true ? { fill: true } : {}),
});
}
case 'ui.rerenderEmail': {
+10 -3
View File
@@ -14,6 +14,7 @@ export type { RegionalSnapshot };
import { withBasePath } from '../browser-navigation';
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
import { snapshotHostFeatures, watchHostFeatures } from './host-features';
import { assistantFeatures } from './assistant';
import { effectiveSettings } from '../plugin-settings';
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import { resolveDateLocale } from '../utils';
@@ -257,7 +258,8 @@ function pushThemeToFrames(): void {
/** Every frame hears about a Bulwark feature that replaces plugins turning on or off. */
function pushHostFeaturesToFrames(features: HostFeaturesView): void {
for (const instance of liveInstances) {
try { instance.setHostFeatures(features); } catch { /* one broken iframe must not stop the rest */ }
// Some of the assistant's flags depend on the plugin's grants (host-features.ts).
try { instance.setHostFeatures({ ...features, ...instance.ownHostFeatures() }); } catch { /* one broken iframe must not stop the rest */ }
}
}
@@ -672,6 +674,11 @@ export class SandboxInstance {
}
/** Push what api.host.features says now; before init it goes into the init payload. */
/** The flags of api.host.features that depend on this plugin. */
ownHostFeatures(): Partial<HostFeaturesView> {
return assistantFeatures(this.plugin);
}
setHostFeatures(features: HostFeaturesView): void {
if (this.destroyed) return;
if (this.pendingInit) {
@@ -782,7 +789,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan
code: opts.code,
locale: opts.locale,
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
hostFeatures: snapshotHostFeatures(),
hostFeatures: snapshotHostFeatures(opts.plugin),
};
return new SandboxInstance(
opts.plugin,
@@ -812,7 +819,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
locale: opts.locale,
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
theme: snapshotHostTheme(),
hostFeatures: snapshotHostFeatures(),
hostFeatures: snapshotHostFeatures(opts.plugin),
};
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown);
}
+6
View File
@@ -26,6 +26,12 @@ export type DialogResult = boolean | Record<string, string> | null;
export interface DialogRequest {
id: string;
/**
* For `kind === 'custom'`: the plugin's frame fills the dialog, which is as
* tall as the window lets it be (at most 640 px), instead of growing with
* its content. For chats, whose input stays at the bottom.
*/
fill?: boolean;
pluginId: string;
kind: DialogKind;
title: string;
+3 -3
View File
@@ -10,7 +10,7 @@
import { usePolicyStore } from '@/stores/policy-store';
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
import type { HostFeaturesView } from '../plugin-types';
import type { HostFeaturesView, InstalledPlugin } from '../plugin-types';
import type { OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { assistantFeatures } from './assistant';
@@ -26,11 +26,11 @@ export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail']
};
}
export function snapshotHostFeatures(): HostFeaturesView {
export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView {
return {
...hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic()),
// The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant).
...assistantFeatures(),
...assistantFeatures(plugin),
};
}
+7 -4
View File
@@ -2,14 +2,17 @@
// (api.ui.openPanel / closePanel / togglePanel): the composer's side panels
// and the reader's detail panel. A toolbar button, a shortcut or a
// right-click entry can then bring the plugin's panel up instead of a
// dialog. The panels subscribe here while they are on screen; a request
// nobody answers (no composer open, no message open, a phone) resolves to
// null, so the plugin can fall back to a dialog.
// dialog. The app-side-panel is the panel of Calendar, Contacts, Files and
// Settings (components/plugins/app-side-panel.tsx). The panels subscribe
// here while they are on screen; a request nobody answers (no composer
// open, no message open, a phone) resolves to null, so the plugin can fall
// back to a dialog. Where the app-side-panel can't dock (phones, Settings
// below 1024 px) it answers false and asks the plugin for its dialog.
//
// When a panel opens on request, its slot gets a new `focusRequest` number
// in its props, so the plugin can put the focus where it belongs.
export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar'] as const;
export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-side-panel'] as const;
export type PanelSlot = typeof PANEL_SLOTS[number];
export type PanelMode = 'open' | 'close' | 'toggle';
+19 -2
View File
@@ -49,7 +49,8 @@ export function slotPropsFor(
slot: SlotName,
props: Record<string, unknown>,
): Record<string, unknown> {
const allowed = slotSpec(slot)?.props;
const spec = slotSpec(slot);
const allowed = spec?.props;
if (allowed === '*') return props;
if (!allowed) return {};
const out: Record<string, unknown> = {};
@@ -57,7 +58,23 @@ export function slotPropsFor(
if (!Object.prototype.hasOwnProperty.call(allowed, key)) continue;
const perm = allowed[key];
if (perm && !pluginHasPermission(plugin, perm)) continue;
out[key] = value;
const fields = spec?.fields?.[key];
out[key] = fields ? fieldsFor(plugin, fields, value) : value;
}
return out;
}
// A prop made of parts with their own permissions (the app-side-panel's
// context: an event, a contact, files): only the parts the plugin may see,
// and none the catalogue doesn't name.
function fieldsFor(plugin: InstalledPlugin, fields: Record<string, Permission | null>, value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: Record<string, unknown> = {};
for (const [field, part] of Object.entries(value as Record<string, unknown>)) {
if (!Object.prototype.hasOwnProperty.call(fields, field)) continue;
const perm = fields[field];
if (perm && !pluginHasPermission(plugin, perm)) continue;
out[field] = part;
}
return out;
}
+7 -2
View File
@@ -162,10 +162,15 @@ function applyHostFeatures(next: HostFeaturesView | undefined): void {
hostFeaturesState.externalRecipientWarning = next.externalRecipientWarning === true;
// A host older than 1.20 doesn't send it, and never let such a plugin run in the mode.
hostFeaturesState.noThirdPartyTraffic = next.noThirdPartyTraffic === true;
// The AI assistant's flags (lib/plugin-sandbox/assistant): whatever the host names, true or false.
// The AI assistant's flags (lib/plugin-sandbox/assistant): whatever the host
// names, true or false; one it no longer names turns false.
const flags = hostFeaturesState as unknown as Record<string, boolean>;
const core = new Set(['externalSenderLabel', 'externalRecipientWarning', 'noThirdPartyTraffic']);
for (const key of Object.keys(flags)) {
if (!core.has(key) && !(key in next)) flags[key] = false;
}
for (const [key, value] of Object.entries(next)) {
if (key !== 'externalSenderLabel' && key !== 'externalRecipientWarning' && key !== 'noThirdPartyTraffic') flags[key] = value === true;
if (!core.has(key)) flags[key] = value === true;
}
for (const listener of hostFeatureListeners) {
try { listener(hostFeaturesState); } catch (err) { console.error('[plugin] host feature listener failed', err); }
+5
View File
@@ -31,6 +31,11 @@ interface NormalisedCombo {
const bindings = new Map<string, Binding>(); // key: `${pluginId}:${shortcutId}`
let listenerInstalled = false;
/** The keys a plugin's shortcut is bound to ("Alt+A"), or null when it has none by that id. */
export function shortcutKeysFor(pluginId: string, shortcutId: string): string | null {
return bindings.get(`${pluginId}:${shortcutId}`)?.keys ?? null;
}
function normaliseCombo(combo: string): NormalisedCombo | null {
if (typeof combo !== 'string') return null;
const parts = combo.split('+').map(p => p.trim()).filter(Boolean);
+1 -1
View File
@@ -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', 'app-page'];
export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-side-panel', 'app-page', 'plugin-dialog'];
export function isFillableSlot(slot: string): boolean {
return FILLABLE_SLOTS.includes(slot);
+7
View File
@@ -14,6 +14,7 @@
// Badges on list rows are data too (the onEmailListItemRender hook).
import type { Permission } from './plugin-types';
import { APP_SIDE_PANEL_CONTEXT_FIELDS } from './plugin-sandbox/assistant/f.types';
export type SlotArea =
| 'app' | 'mail-list' | 'reader' | 'composer' | 'navigation'
@@ -26,6 +27,11 @@ export interface SlotSpec {
permissions: readonly Permission[];
/** Props (frame) or click context (action) and the permission each needs; null = none. */
props: Record<string, Permission | null> | '*';
/**
* Props that are objects whose fields need their own permission: prop →
* field → permission (null = none). Fields not named are dropped.
*/
fields?: Record<string, Record<string, Permission | null>>;
/** Sits in a row of buttons and sizes to its content. */
inline?: boolean;
/** Where it appears. */
@@ -46,6 +52,7 @@ export const SLOT_CATALOG = {
'app-top-banner': S('app', 'frame', ['ui:app-top-banner'], { username: 'account:read', serverUrl: 'account:read', impersonated: 'account:read' }, 'a banner across the top of every page; `impersonated` is true when an admin opened the mailbox through an impersonation handoff'),
'plugin-dialog': S('dialog', 'frame', ['ui:observe'], '*', 'inside a dialog the plugin opened with api.ui.openDialog'),
'command-palette': S('app', 'action', ['ui:commands'], {}, 'entries in the command palette (Ctrl+K)'),
'app-side-panel': { ...S('app', 'frame', ['ui:side-panel'], { view: null, context: null, focusRequest: null }, 'a side panel next to Calendar, Contacts, Files and Settings (desktop and tablet; phones and Settings below 1024 px have none: the toggle there asks the plugin for a dialog through onAction, surface `app-side-panel`, actionId `open`, context { view, context }); `view` names the view ("calendar" | "contacts" | "files" | "settings"); `context` is what it shows: { event } (calendar:read), { contact, selectedCount } (contacts:read), { files, fileCount, folder } (files:read), { page: { id, label } }; `focusRequest` counts up when the panel opens on request'), fields: { context: APP_SIDE_PANEL_CONTEXT_FIELDS } },
// ─── Navigation ────────────────────────────────────────────
'navigation-rail-bottom': S('navigation', 'frame', ['ui:navigation-rail'], {}, 'the bottom of the navigation rail'),