Skip to content

Commit 0d3670e

Browse files
alicodingclaude
andauthored
fix: a plugin's objects survive its removal visibly, and its tool joins the palette (0249/0251 audit riders) (#521)
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.<id> 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. Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ab2e5ec commit 0d3670e

9 files changed

Lines changed: 185 additions & 11 deletions

File tree

frontend/e2e/runtime-plugins.spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,82 @@ test('a URL pasted from another app lands as the claiming plugin object, not a n
211211
await close()
212212
}
213213
})
214+
215+
test('the palette offers the plugin tool as a create command on the atlas surface', async () => {
216+
const { page, close } = await launchWithPlugins(8)
217+
try {
218+
await page.goto('/')
219+
await page.getByRole('link', { name: 'Atlas' }).click()
220+
await expect(page.getByTestId('atlas-board')).toBeVisible()
221+
// Meta+/ is the palette's own binding on the atlas surface (⌘K
222+
// is jump-to-card there -- command-palette.spec.ts's own atlas
223+
// pattern).
224+
await page.keyboard.press('Meta+/')
225+
const dialog = page.getByRole('dialog', { name: 'Command palette' })
226+
await expect(dialog).toBeVisible()
227+
await dialog.getByRole('combobox').fill('Bookmark')
228+
// The plugin's atlas.create.<kind> command -- the same palette
229+
// parity every built-in tool's create command has.
230+
await expect(dialog.getByRole('option', { name: 'Bookmark' })).toBeVisible()
231+
} finally {
232+
await close()
233+
}
234+
})
235+
236+
test('a plugin object placed before its plugin is removed stays visible, honest, and deletable', async () => {
237+
// Two servers over ONE data dir (the persistence.spec restart
238+
// pattern): place with the plugin installed, relaunch with an empty
239+
// plugins dir -- the object must render the fallback face, never
240+
// nothing (the 0249 "objects stay untouched" promise, made visible).
241+
const dir = mkdtempSync(path.join(tmpdir(), 'mill-plugins-e2e-orphan-'))
242+
const pluginsDir = path.join(dir, 'plugins')
243+
mkdirSync(pluginsDir, { recursive: true })
244+
cpSync(path.join(EXAMPLES_PLUGINS_DIR, 'mill-bookmark'), path.join(pluginsDir, 'mill-bookmark'), { recursive: true })
245+
const emptyPluginsDir = path.join(dir, 'plugins-empty')
246+
mkdirSync(emptyPluginsDir, { recursive: true })
247+
const spawnOpts = {
248+
port: RUNTIME_PLUGINS_SERVER_BASE_PORT + 12,
249+
mcpPort: RUNTIME_PLUGINS_MCP_BASE_PORT + 12,
250+
settingsPath: path.join(dir, 'settings.json'),
251+
executionDbPath: path.join(dir, 'exec.db'),
252+
backupDir: path.join(dir, 'backups'),
253+
}
254+
const browser = await chromium.launch()
255+
try {
256+
const first = await spawnMillServer({ ...spawnOpts, extraEnv: { MILL_PLUGINS_DIR: pluginsDir } })
257+
const page1 = await browser.newPage({ baseURL: first.baseURL })
258+
await page1.goto('/')
259+
await page1.getByRole('link', { name: 'Atlas' }).click()
260+
const board = page1.getByTestId('atlas-board')
261+
await expect(board).toBeVisible()
262+
await page1.locator('[data-testid="atlas-creation-tray"] button[aria-label="Bookmark"]').click()
263+
const spot = await findEmptyBoardRect(page1, board, 300, 200)
264+
const bb = await board.boundingBox()
265+
if (!bb) throw new Error('board has no bounding box')
266+
await board.click({ position: { x: spot.x - bb.x + 10, y: spot.y - bb.y + 10 } })
267+
await expect(page1.locator('[data-testid="plugin-face-bookmark"]')).toBeVisible()
268+
await page1.close()
269+
await first.stop()
270+
271+
const second = await spawnMillServer({ ...spawnOpts, extraEnv: { MILL_PLUGINS_DIR: emptyPluginsDir } })
272+
const page2 = await browser.newPage({ baseURL: second.baseURL })
273+
await page2.goto('/')
274+
await page2.getByRole('link', { name: 'Atlas' }).click()
275+
const face = page2.getByTestId('atlas-unknown-kind-face')
276+
await expect(face).toBeVisible()
277+
await expect(face).toContainText("Its extension isn't running")
278+
// Still a real, selectable object -- delete it through the
279+
// standard context-menu door (atlas-diagram-object.spec.ts's own
280+
// object-menu pattern: the shared context-menu testid + a plain
281+
// Delete text item, never an ARIA menuitem role).
282+
await face.click({ button: 'right' })
283+
const menu = page2.getByTestId('context-menu')
284+
await expect(menu).toBeVisible()
285+
await menu.getByText('Delete', { exact: true }).click()
286+
await expect(page2.getByTestId('atlas-unknown-kind-face')).toHaveCount(0)
287+
await second.stop()
288+
} finally {
289+
await browser.close()
290+
rmSync(dir, { recursive: true, force: true })
291+
}
292+
})

frontend/src/atlas/AtlasBoardObjectNode.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { NodeProps, Node as RFNode } from '@xyflow/react'
55
import type { BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
66
import { AtlasService } from '../shared/bindings'
77
import { boardObjectContentFor } from './atlasNounRegistry'
8+
import { unknownKindContent } from './atlasBoardObjectContent'
89
import { AtlasShapeRotateHandle } from './AtlasShapeRotateHandle'
910
import { useAtlasMirrorChanged } from './useAtlasMirrorChanged'
1011
import { useAtlasObjectMirrorRead } from './useAtlasObjectMirrorRead'
@@ -83,15 +84,12 @@ function AtlasBoardObjectNodeInner({ data, selected }: NodeProps<AtlasBoardObjec
8384
// (rules-of-hooks: `facts` may be undefined on the first render).
8485
const mirrorContent = useAtlasObjectMirrorRead(object.ID, object.Payload?.mirrorPath, facts?.fileBacked ?? false, mirrorVersion)
8586

86-
if (!facts) {
87-
// Every persisted Kind self-registers a content contribution
88-
// (goal 0215 S3) -- reaching here means a BoardObject exists whose
89-
// own Kind has none, a registry/data mismatch this renderer cannot
90-
// recover from.
91-
console.error(`atlas board object "${object.ID}" has unregistered Kind "${object.Kind}"`)
92-
return null
93-
}
94-
const { Component, ariaLabelKey, role, dragBand } = facts
87+
// An unregistered Kind renders the fallback face instead of null
88+
// (docs/goals/0249's audit rider): a disabled/uninstalled plugin's
89+
// objects stay visible, selectable and deletable, and a built-in
90+
// registry/data mismatch becomes VISIBLE on the board instead of an
91+
// invisible node only a console reader could diagnose.
92+
const { Component, ariaLabelKey, role, dragBand } = facts ?? unknownKindContent
9593
// ADR-0046 (goal 0244 S1): double-click dispatches through the
9694
// object's own DECLARED edit route (resolved per-object, since a Kind
9795
// like diagram opens different doors for different mirror
@@ -101,7 +99,7 @@ function AtlasBoardObjectNodeInner({ data, selected }: NodeProps<AtlasBoardObjec
10199
// case) stays reachable via the context menu / an explicit button
102100
// only, matching every other file-backed Kind's convention of never
103101
// launching another app on an accidental double-click.
104-
const editRoute = facts.editRoute ? resolveEditRoute(object, facts.editRoute) : undefined
102+
const editRoute = facts?.editRoute ? resolveEditRoute(object, facts.editRoute) : undefined
105103
const editable = editRoute?.kind === 'embedded-engine'
106104

107105
return (
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* The unregistered-Kind fallback face (AtlasUnknownKindContent.tsx).
2+
Primer tokens only; a muted, dashed-border card so the object reads
3+
as present-but-inactive at a glance, never mistakable for a live
4+
plugin face. */
5+
6+
.face {
7+
display: flex;
8+
flex-direction: column;
9+
gap: var(--base-size-4, 4px);
10+
padding: var(--base-size-8, 8px) var(--base-size-12, 12px);
11+
border: 1px dashed var(--borderColor-muted);
12+
border-radius: var(--borderRadius-medium, 6px);
13+
background: var(--bgColor-muted);
14+
color: var(--fgColor-muted);
15+
height: 100%;
16+
width: 100%;
17+
overflow: hidden;
18+
}
19+
20+
.title {
21+
font-weight: var(--base-text-weight-medium, 500);
22+
color: var(--fgColor-default);
23+
overflow: hidden;
24+
text-overflow: ellipsis;
25+
white-space: nowrap;
26+
}
27+
28+
.note {
29+
font-size: var(--text-body-size-small, 12px);
30+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useTranslation } from 'react-i18next'
2+
import type { BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
3+
import styles from './AtlasUnknownKindContent.module.css'
4+
5+
// The board face for an object whose Kind has NO registered content
6+
// (docs/goals/0249's audit rider): a plugin turned off or uninstalled
7+
// leaves its objects on the board by promise ("objects it already
8+
// placed stay untouched"), and an ingestion claim can land an object
9+
// before its plugin ever renders -- both previously rendered NOTHING
10+
// (AtlasBoardObjectNode returned null), an invisible, unselectable
11+
// node. This face keeps the object visible, selectable, and deletable,
12+
// and says honestly why it isn't rendering.
13+
export function AtlasUnknownKindContent({ object }: { object: BoardObject; mirrorVersion: number }) {
14+
const { t } = useTranslation('atlas')
15+
return (
16+
<div className={styles.face} data-testid="atlas-unknown-kind-face">
17+
<span className={styles.title}>{object.Payload?.title || object.Kind}</span>
18+
<span className={styles.note}>{t('unknownKind.note', { kind: object.Kind })}</span>
19+
</div>
20+
)
21+
}

frontend/src/atlas/atlasBoardObjectContent.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ListProjection } from '../../bindings/github.com/alicoding/mill/in
55
import type { EditRouteDecl, ObjectSource } from './objectSeams'
66
import type { MirrorReadState } from './useAtlasObjectMirrorRead'
77
import type { AtlasNounGroup } from './atlasNounRegistry'
8+
import { AtlasUnknownKindContent } from './AtlasUnknownKindContent'
89

910
// The board-object CONTENT registry -- split out of atlasNounRegistry.ts
1011
// (architecture.md's 500-line file limit) as its own real seam: this
@@ -167,6 +168,22 @@ export function boardObjectContentFor(kind: string): AtlasBoardObjectContent | u
167168
return boardObjectContentRegistry.get(kind)
168169
}
169170

171+
// unknownKindContent -- the fallback record AtlasBoardObjectNode uses
172+
// when boardObjectContentFor misses (docs/goals/0249's audit rider):
173+
// a disabled/uninstalled plugin's objects, and an ingestion-claimed
174+
// kind whose plugin never registered, must stay VISIBLE, selectable
175+
// and deletable rather than rendering null. Board-local and inert:
176+
// no file backing, no drag band (nothing to scrub), no edit route.
177+
export const unknownKindContent: AtlasBoardObjectContent = {
178+
Component: AtlasUnknownKindContent,
179+
ariaLabelKey: 'unknownKind.aria',
180+
role: undefined,
181+
source: { kind: 'board-local' },
182+
editRoute: { kind: 'none' },
183+
dragBand: false,
184+
fileBacked: false,
185+
}
186+
170187
// ToolLessNounExtension -- one entry of toolLessNounExtensions() below,
171188
// with `extension` already narrowed to non-optional (the filter that
172189
// builds this array is the one place that check happens, so every

frontend/src/locales/en/atlas/shared.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,5 +453,9 @@
453453
"errorTitle": "That didn't work",
454454
"retry": "Retry",
455455
"close": "Close AI panel"
456+
},
457+
"unknownKind": {
458+
"note": "Its extension isn't running. Turn it on in Settings to bring it back.",
459+
"aria": "Object from an extension that isn't running"
456460
}
457461
}

frontend/src/plugins/hostApi.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { registerThirdPartyNoun } from '../atlas/atlasNounRegistry'
44
import { PluginService } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc'
55
import type { Manifest } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models'
66
import { ingestionClaimMismatch } from './ingestionClaims'
7+
import { useUISignalStore } from '../shared/uiSignalStore'
8+
import type { AtlasArmRequestTool } from '../shared/atlasToolIdentity'
79
import { collectPluginCommand } from './pluginCommands'
810
import { pluginFaceComponent } from './PluginFaceContent'
911
import type { CanvasObjectDecl, MillPluginAPI } from './sdk'
@@ -83,6 +85,24 @@ export function buildPluginAPI(manifest: Manifest, millVersion: string): MillPlu
8385
throw new Error('third-party placement goes through useAtlasCreation’s generic branch, never commit()')
8486
},
8587
})
88+
// The palette parity built-in tools already have (their
89+
// atlas.create.<id> commands, shared/atlasCreateCommands.ts):
90+
// a plugin's tool gets the same registry command through the
91+
// same collector its own commands ride, arming the identical
92+
// placement mechanism the tray click uses. Enablement is
93+
// structural -- a disabled plugin never activates, so its
94+
// command is never collected.
95+
collectPluginCommand({
96+
id: `atlas.create.${decl.kind}`,
97+
label: decl.label,
98+
surface: ['atlas'],
99+
// The arm signal's type is the built-in literal union; the
100+
// runtime gate already accepts any registered third-party
101+
// id (useAtlasCreation's isThirdPartyToolId OR) -- the
102+
// same one-documented-cast convention
103+
// orderedRegisteredTools carries for the registry itself.
104+
run: () => useUISignalStore.getState().requestAtlasArmTool(decl.kind as AtlasArmRequestTool),
105+
})
86106
},
87107
registerCommand: (decl) => {
88108
collectPluginCommand({ id: `plugin.${pluginId}.${decl.id}`, label: decl.label, run: decl.run })

frontend/src/plugins/pluginCommands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export interface RuntimeCommandDecl {
99
id: string
1010
label: string
1111
run: () => void
12+
// surface scopes the command to a view the way Command.surface does
13+
// (docs/goals/0251 audit rider: a plugin object's own create
14+
// command belongs to the atlas surface, exactly like the built-in
15+
// tools' atlas.create.<id> commands) -- omitted means global.
16+
surface?: import('../shared/commands').Command['surface']
1217
}
1318

1419
const collected: RuntimeCommandDecl[] = []

frontend/src/shared/commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ export const COMMANDS: Command[] = lazyArray(() => [
411411
// default-bound -- a plugin command is palette-reachable; a
412412
// keybinding for third-party code is assigned in Settings, never
413413
// shipped by the plugin.
414-
...drainedPluginCommands().map((c) => ({ id: c.id, label: c.label, defaultBinding: null, run: c.run })),
414+
...drainedPluginCommands().map((c) => ({ id: c.id, label: c.label, defaultBinding: null, surface: c.surface, run: c.run })),
415415
])
416416

417417
export function findCommand(id: string): Command | undefined {

0 commit comments

Comments
 (0)