From 739b1b9baa7508d9f7783295804c12f11a423a37 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Sat, 29 Aug 2026 15:08:28 -0400 Subject: [PATCH] fix: a plugin's objects survive its removal visibly, and its tool joins the palette (0249/0251 audit riders) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh gap audit of the plugin platform found two real holes. (1) An unregistered Kind rendered null — a disabled/uninstalled plugin's already-placed objects became invisible, unselectable and undeletable, silently breaking the 'objects stay untouched' promise; same for an ingestion-claimed kind whose plugin never registered. They now render a neutral fallback face (dashed, muted, names why it isn't live) and stay deletable through the standard context menu — proven by a two-server restart e2e. (2) Built-in tools each have an atlas.create. palette command; plugin tools had none — the host now collects one per registered object through the same channel plugin commands ride (surface-scoped to atlas, arming the identical placement mechanism), proven by a palette e2e. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- frontend/e2e/runtime-plugins.spec.ts | 79 +++++++++++++++++++ frontend/src/atlas/AtlasBoardObjectNode.tsx | 18 ++--- .../atlas/AtlasUnknownKindContent.module.css | 30 +++++++ .../src/atlas/AtlasUnknownKindContent.tsx | 21 +++++ frontend/src/atlas/atlasBoardObjectContent.ts | 17 ++++ frontend/src/locales/en/atlas/shared.json | 4 + frontend/src/plugins/hostApi.ts | 20 +++++ frontend/src/plugins/pluginCommands.ts | 5 ++ frontend/src/shared/commands.ts | 2 +- 9 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 frontend/src/atlas/AtlasUnknownKindContent.module.css create mode 100644 frontend/src/atlas/AtlasUnknownKindContent.tsx diff --git a/frontend/e2e/runtime-plugins.spec.ts b/frontend/e2e/runtime-plugins.spec.ts index b033737e4..7d6bda935 100644 --- a/frontend/e2e/runtime-plugins.spec.ts +++ b/frontend/e2e/runtime-plugins.spec.ts @@ -211,3 +211,82 @@ test('a URL pasted from another app lands as the claiming plugin object, not a n await close() } }) + +test('the palette offers the plugin tool as a create command on the atlas surface', async () => { + const { page, close } = await launchWithPlugins(8) + try { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + // Meta+/ is the palette's own binding on the atlas surface (⌘K + // is jump-to-card there -- command-palette.spec.ts's own atlas + // pattern). + await page.keyboard.press('Meta+/') + const dialog = page.getByRole('dialog', { name: 'Command palette' }) + await expect(dialog).toBeVisible() + await dialog.getByRole('combobox').fill('Bookmark') + // The plugin's atlas.create. command -- the same palette + // parity every built-in tool's create command has. + await expect(dialog.getByRole('option', { name: 'Bookmark' })).toBeVisible() + } finally { + await close() + } +}) + +test('a plugin object placed before its plugin is removed stays visible, honest, and deletable', async () => { + // Two servers over ONE data dir (the persistence.spec restart + // pattern): place with the plugin installed, relaunch with an empty + // plugins dir -- the object must render the fallback face, never + // nothing (the 0249 "objects stay untouched" promise, made visible). + const dir = mkdtempSync(path.join(tmpdir(), 'mill-plugins-e2e-orphan-')) + const pluginsDir = path.join(dir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + cpSync(path.join(EXAMPLES_PLUGINS_DIR, 'mill-bookmark'), path.join(pluginsDir, 'mill-bookmark'), { recursive: true }) + const emptyPluginsDir = path.join(dir, 'plugins-empty') + mkdirSync(emptyPluginsDir, { recursive: true }) + const spawnOpts = { + port: RUNTIME_PLUGINS_SERVER_BASE_PORT + 12, + mcpPort: RUNTIME_PLUGINS_MCP_BASE_PORT + 12, + settingsPath: path.join(dir, 'settings.json'), + executionDbPath: path.join(dir, 'exec.db'), + backupDir: path.join(dir, 'backups'), + } + const browser = await chromium.launch() + try { + const first = await spawnMillServer({ ...spawnOpts, extraEnv: { MILL_PLUGINS_DIR: pluginsDir } }) + const page1 = await browser.newPage({ baseURL: first.baseURL }) + await page1.goto('/') + await page1.getByRole('link', { name: 'Atlas' }).click() + const board = page1.getByTestId('atlas-board') + await expect(board).toBeVisible() + await page1.locator('[data-testid="atlas-creation-tray"] button[aria-label="Bookmark"]').click() + const spot = await findEmptyBoardRect(page1, board, 300, 200) + const bb = await board.boundingBox() + if (!bb) throw new Error('board has no bounding box') + await board.click({ position: { x: spot.x - bb.x + 10, y: spot.y - bb.y + 10 } }) + await expect(page1.locator('[data-testid="plugin-face-bookmark"]')).toBeVisible() + await page1.close() + await first.stop() + + const second = await spawnMillServer({ ...spawnOpts, extraEnv: { MILL_PLUGINS_DIR: emptyPluginsDir } }) + const page2 = await browser.newPage({ baseURL: second.baseURL }) + await page2.goto('/') + await page2.getByRole('link', { name: 'Atlas' }).click() + const face = page2.getByTestId('atlas-unknown-kind-face') + await expect(face).toBeVisible() + await expect(face).toContainText("Its extension isn't running") + // Still a real, selectable object -- delete it through the + // standard context-menu door (atlas-diagram-object.spec.ts's own + // object-menu pattern: the shared context-menu testid + a plain + // Delete text item, never an ARIA menuitem role). + await face.click({ button: 'right' }) + const menu = page2.getByTestId('context-menu') + await expect(menu).toBeVisible() + await menu.getByText('Delete', { exact: true }).click() + await expect(page2.getByTestId('atlas-unknown-kind-face')).toHaveCount(0) + await second.stop() + } finally { + await browser.close() + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/frontend/src/atlas/AtlasBoardObjectNode.tsx b/frontend/src/atlas/AtlasBoardObjectNode.tsx index f4b979e5c..0d041eb1c 100644 --- a/frontend/src/atlas/AtlasBoardObjectNode.tsx +++ b/frontend/src/atlas/AtlasBoardObjectNode.tsx @@ -5,6 +5,7 @@ import type { NodeProps, Node as RFNode } from '@xyflow/react' import type { BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { AtlasService } from '../shared/bindings' import { boardObjectContentFor } from './atlasNounRegistry' +import { unknownKindContent } from './atlasBoardObjectContent' import { AtlasShapeRotateHandle } from './AtlasShapeRotateHandle' import { useAtlasMirrorChanged } from './useAtlasMirrorChanged' import { useAtlasObjectMirrorRead } from './useAtlasObjectMirrorRead' @@ -83,15 +84,12 @@ function AtlasBoardObjectNodeInner({ data, selected }: NodeProps + {object.Payload?.title || object.Kind} + {t('unknownKind.note', { kind: object.Kind })} + + ) +} diff --git a/frontend/src/atlas/atlasBoardObjectContent.ts b/frontend/src/atlas/atlasBoardObjectContent.ts index 6676e3bc2..52ff6acd8 100644 --- a/frontend/src/atlas/atlasBoardObjectContent.ts +++ b/frontend/src/atlas/atlasBoardObjectContent.ts @@ -5,6 +5,7 @@ import type { ListProjection } from '../../bindings/github.com/alicoding/mill/in import type { EditRouteDecl, ObjectSource } from './objectSeams' import type { MirrorReadState } from './useAtlasObjectMirrorRead' import type { AtlasNounGroup } from './atlasNounRegistry' +import { AtlasUnknownKindContent } from './AtlasUnknownKindContent' // The board-object CONTENT registry -- split out of atlasNounRegistry.ts // (architecture.md's 500-line file limit) as its own real seam: this @@ -167,6 +168,22 @@ export function boardObjectContentFor(kind: string): AtlasBoardObjectContent | u return boardObjectContentRegistry.get(kind) } +// unknownKindContent -- the fallback record AtlasBoardObjectNode uses +// when boardObjectContentFor misses (docs/goals/0249's audit rider): +// a disabled/uninstalled plugin's objects, and an ingestion-claimed +// kind whose plugin never registered, must stay VISIBLE, selectable +// and deletable rather than rendering null. Board-local and inert: +// no file backing, no drag band (nothing to scrub), no edit route. +export const unknownKindContent: AtlasBoardObjectContent = { + Component: AtlasUnknownKindContent, + ariaLabelKey: 'unknownKind.aria', + role: undefined, + source: { kind: 'board-local' }, + editRoute: { kind: 'none' }, + dragBand: false, + fileBacked: false, +} + // ToolLessNounExtension -- one entry of toolLessNounExtensions() below, // with `extension` already narrowed to non-optional (the filter that // builds this array is the one place that check happens, so every diff --git a/frontend/src/locales/en/atlas/shared.json b/frontend/src/locales/en/atlas/shared.json index a5bc9f36f..6c4ab627d 100644 --- a/frontend/src/locales/en/atlas/shared.json +++ b/frontend/src/locales/en/atlas/shared.json @@ -453,5 +453,9 @@ "errorTitle": "That didn't work", "retry": "Retry", "close": "Close AI panel" + }, + "unknownKind": { + "note": "Its extension isn't running. Turn it on in Settings to bring it back.", + "aria": "Object from an extension that isn't running" } } diff --git a/frontend/src/plugins/hostApi.ts b/frontend/src/plugins/hostApi.ts index f3f003ec3..807ae8e85 100644 --- a/frontend/src/plugins/hostApi.ts +++ b/frontend/src/plugins/hostApi.ts @@ -4,6 +4,8 @@ import { registerThirdPartyNoun } from '../atlas/atlasNounRegistry' import { PluginService } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc' import type { Manifest } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models' import { ingestionClaimMismatch } from './ingestionClaims' +import { useUISignalStore } from '../shared/uiSignalStore' +import type { AtlasArmRequestTool } from '../shared/atlasToolIdentity' import { collectPluginCommand } from './pluginCommands' import { pluginFaceComponent } from './PluginFaceContent' import type { CanvasObjectDecl, MillPluginAPI } from './sdk' @@ -83,6 +85,24 @@ export function buildPluginAPI(manifest: Manifest, millVersion: string): MillPlu throw new Error('third-party placement goes through useAtlasCreation’s generic branch, never commit()') }, }) + // The palette parity built-in tools already have (their + // atlas.create. commands, shared/atlasCreateCommands.ts): + // a plugin's tool gets the same registry command through the + // same collector its own commands ride, arming the identical + // placement mechanism the tray click uses. Enablement is + // structural -- a disabled plugin never activates, so its + // command is never collected. + collectPluginCommand({ + id: `atlas.create.${decl.kind}`, + label: decl.label, + surface: ['atlas'], + // The arm signal's type is the built-in literal union; the + // runtime gate already accepts any registered third-party + // id (useAtlasCreation's isThirdPartyToolId OR) -- the + // same one-documented-cast convention + // orderedRegisteredTools carries for the registry itself. + run: () => useUISignalStore.getState().requestAtlasArmTool(decl.kind as AtlasArmRequestTool), + }) }, registerCommand: (decl) => { collectPluginCommand({ id: `plugin.${pluginId}.${decl.id}`, label: decl.label, run: decl.run }) diff --git a/frontend/src/plugins/pluginCommands.ts b/frontend/src/plugins/pluginCommands.ts index 8ff1a3494..76fc3c29d 100644 --- a/frontend/src/plugins/pluginCommands.ts +++ b/frontend/src/plugins/pluginCommands.ts @@ -9,6 +9,11 @@ export interface RuntimeCommandDecl { id: string label: string run: () => void + // surface scopes the command to a view the way Command.surface does + // (docs/goals/0251 audit rider: a plugin object's own create + // command belongs to the atlas surface, exactly like the built-in + // tools' atlas.create. commands) -- omitted means global. + surface?: import('../shared/commands').Command['surface'] } const collected: RuntimeCommandDecl[] = [] diff --git a/frontend/src/shared/commands.ts b/frontend/src/shared/commands.ts index 60f05af35..c10dd0c67 100644 --- a/frontend/src/shared/commands.ts +++ b/frontend/src/shared/commands.ts @@ -411,7 +411,7 @@ export const COMMANDS: Command[] = lazyArray(() => [ // default-bound -- a plugin command is palette-reachable; a // keybinding for third-party code is assigned in Settings, never // shipped by the plugin. - ...drainedPluginCommands().map((c) => ({ id: c.id, label: c.label, defaultBinding: null, run: c.run })), + ...drainedPluginCommands().map((c) => ({ id: c.id, label: c.label, defaultBinding: null, surface: c.surface, run: c.run })), ]) export function findCommand(id: string): Command | undefined {