Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions frontend/e2e/runtime-plugins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<kind> 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 })
}
})
18 changes: 8 additions & 10 deletions frontend/src/atlas/AtlasBoardObjectNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -83,15 +84,12 @@ function AtlasBoardObjectNodeInner({ data, selected }: NodeProps<AtlasBoardObjec
// (rules-of-hooks: `facts` may be undefined on the first render).
const mirrorContent = useAtlasObjectMirrorRead(object.ID, object.Payload?.mirrorPath, facts?.fileBacked ?? false, mirrorVersion)

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

return (
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/atlas/AtlasUnknownKindContent.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* The unregistered-Kind fallback face (AtlasUnknownKindContent.tsx).
Primer tokens only; a muted, dashed-border card so the object reads
as present-but-inactive at a glance, never mistakable for a live
plugin face. */

.face {
display: flex;
flex-direction: column;
gap: var(--base-size-4, 4px);
padding: var(--base-size-8, 8px) var(--base-size-12, 12px);
border: 1px dashed var(--borderColor-muted);
border-radius: var(--borderRadius-medium, 6px);
background: var(--bgColor-muted);
color: var(--fgColor-muted);
height: 100%;
width: 100%;
overflow: hidden;
}

.title {
font-weight: var(--base-text-weight-medium, 500);
color: var(--fgColor-default);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.note {
font-size: var(--text-body-size-small, 12px);
}
21 changes: 21 additions & 0 deletions frontend/src/atlas/AtlasUnknownKindContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useTranslation } from 'react-i18next'
import type { BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
import styles from './AtlasUnknownKindContent.module.css'

// The board face for an object whose Kind has NO registered content
// (docs/goals/0249's audit rider): a plugin turned off or uninstalled
// leaves its objects on the board by promise ("objects it already
// placed stay untouched"), and an ingestion claim can land an object
// before its plugin ever renders -- both previously rendered NOTHING
// (AtlasBoardObjectNode returned null), an invisible, unselectable
// node. This face keeps the object visible, selectable, and deletable,
// and says honestly why it isn't rendering.
export function AtlasUnknownKindContent({ object }: { object: BoardObject; mirrorVersion: number }) {
const { t } = useTranslation('atlas')
return (
<div className={styles.face} data-testid="atlas-unknown-kind-face">
<span className={styles.title}>{object.Payload?.title || object.Kind}</span>
<span className={styles.note}>{t('unknownKind.note', { kind: object.Kind })}</span>
</div>
)
}
17 changes: 17 additions & 0 deletions frontend/src/atlas/atlasBoardObjectContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/locales/en/atlas/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
20 changes: 20 additions & 0 deletions frontend/src/plugins/hostApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.<id> 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 })
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/plugins/pluginCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id> commands) -- omitted means global.
surface?: import('../shared/commands').Command['surface']
}

const collected: RuntimeCommandDecl[] = []
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/shared/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading