From d6e23bc7739909882e103b7204fb5040310d36b0 Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Mon, 31 Aug 2026 15:35:56 -0400 Subject: [PATCH] editor: don't put an empty plugin manager in an empty rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open lobby (`/play/`) mounts the editor under a read-only lease and registers no host panels, so the plugin *manager* was the only tab in the rail — and the rail opens on its first tab. A visitor who left the game found a bare "Plugins" heading covering ~40% of the window over the world they had come to play in. Gate the manager on having something to manage, or on a writable scene: - `managedPluginIds(panels)` — the distinct plugins behind the registered panels, since one plugin may contribute several and the manager lists plugins. - `showsPluginManager({managedPluginCount, readOnly, workspaceMode})` — the tab earns its slot when a plugin is registered, or when the scene is writable and "Create a Pascal plugin" is still worth offering. The only case this drops is read-only *and* nothing registered, which is exactly the lobby; with no tabs at all both the v2 and mobile layouts already skip the sidebar entirely, leaving the canvas alone. A read-only editor keeps the tab as soon as a plugin is registered — browsing what a project uses is a read, and the install button was already disabled on its own. Writable edit-workspace behaviour is unchanged, and fenced by a test that says so. Co-Authored-By: Claude Opus 5 --- .../ui/sidebar/use-plugin-panels.tsx | 19 +++- packages/editor/src/lib/plugin-panels.test.ts | 93 ++++++++++++++++++- packages/editor/src/lib/plugin-panels.ts | 48 ++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx index d9ccfd046e..25c98ad9c5 100644 --- a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx +++ b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx @@ -12,7 +12,12 @@ import { useSyncExternalStore, } from 'react' import useEditor from '../../../store/use-editor' -import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels' +import { + editorHostPanelRegistry, + type EditorHostPanel, + managedPluginIds, + showsPluginManager, +} from '../../../lib/plugin-panels' import { ErrorBoundary } from '../primitives/error-boundary' import type { ExtraPanel } from './icon-rail' import { PluginsPanel } from './panels/plugins-panel' @@ -100,6 +105,7 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { ) const workspaceMode = useEditor((s) => s.workspaceMode) const installedPlugins = useScene((s) => s.installedPlugins) + const readOnly = useScene((s) => s.readOnly) const hostIds = new Set(hostPanels?.map((p) => p.id)) useEffect(() => { @@ -126,7 +132,16 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { pluginId: p.pluginId, }), ) + // The manager tab is the one panel the editor contributes itself, so it is + // also the one that can be alone in the rail — see `showsPluginManager`. const manager = - workspaceMode === 'edit' && !hostIds.has(pluginsManagerPanel.id) ? [pluginsManagerPanel] : [] + !hostIds.has(pluginsManagerPanel.id) && + showsPluginManager({ + managedPluginCount: managedPluginIds(registered).length, + readOnly, + workspaceMode, + }) + ? [pluginsManagerPanel] + : [] return [...(hostPanels ?? []), ...fromRegistry, ...manager] } diff --git a/packages/editor/src/lib/plugin-panels.test.ts b/packages/editor/src/lib/plugin-panels.test.ts index b702b2a808..bf47c4ceac 100644 --- a/packages/editor/src/lib/plugin-panels.test.ts +++ b/packages/editor/src/lib/plugin-panels.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { editorHostPanelRegistry, registerEditorHostPanel } from './plugin-panels' +import { + type EditorHostPanel, + editorHostPanelRegistry, + managedPluginIds, + registerEditorHostPanel, + showsPluginManager, +} from './plugin-panels' describe('editorHostPanelRegistry', () => { afterEach(() => editorHostPanelRegistry.reset()) @@ -17,3 +23,88 @@ describe('editorHostPanelRegistry', () => { expect(editorHostPanelRegistry.panelForKind('wall')).toBeUndefined() }) }) + +const panel = (id: string, pluginId?: string): EditorHostPanel => ({ + component: async () => ({ default: () => null }), + icon: { kind: 'url', src: '/x.webp' }, + id, + label: id, + ...(pluginId ? { pluginId } : {}), +}) + +describe('managedPluginIds', () => { + test('counts plugins, not panels — one plugin with two panels is one plugin', () => { + expect( + managedPluginIds([ + panel('pascal:boots:game', 'pascal:boots'), + panel('pascal:boots:keep', 'pascal:boots'), + panel('pascal:trees:nature', 'pascal:trees'), + ]), + ).toEqual(['pascal:boots', 'pascal:trees']) + }) + + test("the editor's own panels are not plugins", () => { + // No `pluginId` means it came from the host app, not from a plugin, and + // there is nothing to install or uninstall. + expect(managedPluginIds([panel('site'), panel('settings')])).toEqual([]) + expect(managedPluginIds([])).toEqual([]) + }) +}) + +/** + * THE EMPTY LOBBY PANEL (owner report 2026-08-31). `/play/` mounts the + * editor read-only and registers no host panels, so the plugin *manager* was + * the only tab in the rail: it opened by default onto a bare "Plugins" heading + * eating ~40% of a visitor's window. Dropping the last tab makes the v2 layout + * drop the left column entirely, which is the lobby as designed. + * + * The line to hold is that this hides an EMPTY manager and never a populated + * one — a read-only viewer in the real editor can still see what a project uses. + */ +describe('showsPluginManager', () => { + test('the open lobby — read-only with nothing registered — gets no rail at all', () => { + expect( + showsPluginManager({ managedPluginCount: 0, readOnly: true, workspaceMode: 'edit' }), + ).toBe(false) + }) + + test('a read-only editor keeps the manager as soon as a plugin is registered', () => { + // Browsing what a project uses is a read, and the install button is + // already disabled on its own. + expect( + showsPluginManager({ managedPluginCount: 1, readOnly: true, workspaceMode: 'edit' }), + ).toBe(true) + }) + + test('a writable scene always keeps it, even with zero plugins', () => { + // The empty state is still useful to an owner: it is where "Create a + // Pascal plugin" lives. + expect( + showsPluginManager({ managedPluginCount: 0, readOnly: false, workspaceMode: 'edit' }), + ).toBe(true) + expect( + showsPluginManager({ managedPluginCount: 3, readOnly: false, workspaceMode: 'edit' }), + ).toBe(true) + }) + + test('never outside the edit workspace — studio has its own rail', () => { + for (const readOnly of [false, true]) { + for (const managedPluginCount of [0, 2]) { + expect( + showsPluginManager({ managedPluginCount, readOnly, workspaceMode: 'studio' }), + `readOnly=${readOnly} count=${managedPluginCount}`, + ).toBe(false) + } + } + }) + + test('the pre-existing behaviour is unchanged for the normal editor', () => { + // Regression fence: before this gate the rule was `workspaceMode === 'edit'` + // alone. Every writable edit-workspace case must still answer the same. + for (const managedPluginCount of [0, 1, 5]) { + expect( + showsPluginManager({ managedPluginCount, readOnly: false, workspaceMode: 'edit' }), + ).toBe(true) + } + }) +}) diff --git a/packages/editor/src/lib/plugin-panels.ts b/packages/editor/src/lib/plugin-panels.ts index 52057fdadc..84b0913abc 100644 --- a/packages/editor/src/lib/plugin-panels.ts +++ b/packages/editor/src/lib/plugin-panels.ts @@ -89,3 +89,51 @@ export const editorHostPanelRegistry = new EditorHostPanelRegistryImpl() export function registerEditorHostPanel(panel: EditorHostPanel): void { editorHostPanelRegistry.registerPanel(panel) } + +/** + * The distinct plugins the manager can act on — every registered panel that + * declares a `pluginId`, deduplicated, because one plugin may contribute + * several panels and the manager lists plugins, not panels. + * + * Registration is what makes a plugin *manageable*, not installation: an + * uninstalled plugin still has to appear so it can be installed. + */ +export function managedPluginIds(panels: readonly EditorHostPanel[]): string[] { + return Array.from( + new Set(panels.filter((panel) => panel.pluginId).map((panel) => panel.pluginId as string)), + ) +} + +/** + * Does the plugin *manager* tab belong in the rail? + * + * It is a management surface — it installs and uninstalls plugins into the + * scene — so it earns a slot when there is something to manage, or when the + * scene is writable and the "create a plugin" path is still worth offering to + * whoever owns it. + * + * That leaves exactly one case out, and it is a real screen rather than a + * hypothetical: the open lobby (`/play/`) mounts the editor under a + * read-only lease and registers NO host panels, so the manager was the only + * tab in the rail. The rail therefore opened by default onto a "Plugins" + * heading with nothing under it, covering roughly 40% of a visitor's window + * over the world they had come to play in (owner report 2026-08-31). With no + * tabs at all the v2 layout drops the whole left column, which is the lobby as + * intended: the canvas, and nothing else. + * + * A read-only *editor* keeps the tab as long as plugins are registered — a + * viewer can still read what a project uses; only the install button is + * disabled. So this hides an empty panel, never a populated one. + */ +export function showsPluginManager({ + managedPluginCount, + readOnly, + workspaceMode, +}: { + managedPluginCount: number + readOnly: boolean + workspaceMode: string +}): boolean { + if (workspaceMode !== 'edit') return false + return managedPluginCount > 0 || !readOnly +}