Parent Issue
Part of Epic #445: Custom Entity Types - User-Defined Schemas with Natural Language Creation
Problem
When a user creates a custom entity schema (e.g., "invoice"), the schema is stored in the database but does not automatically become available as a slash command. Users must manually register a plugin, which defeats the purpose of dynamic entity creation.
Current workflow (manual):
// 1. User creates schema via AI
await schemaService.createSchema({ id: 'invoice', fields: [...] });
// 2. Developer must manually register plugin (users can't do this!)
pluginRegistry.register({
id: 'invoice',
name: 'Invoice',
config: { slashCommands: [...] }
});
// 3. Only then does "/invoice" appear in slash menu
Desired workflow (automatic):
// 1. User creates schema via AI
await schemaService.createSchema({ id: 'invoice', fields: [...] });
// 2. Plugin auto-registered immediately ✨
// 3. "/invoice" appears in slash menu (no restart needed)
Solution
Implement immediate plugin registration when schemas are created. This leverages the existing hot-reloading capabilities discovered in the plugin registry.
Architecture
Key Discovery: The plugin registry already supports runtime registration without restart!
pluginRegistry.register() can be called anytime
SlashCommandService.getCommands() queries registry fresh on every / trigger
- No caching layer blocks dynamic updates
Implementation Approach: Schema-to-Plugin Converter
Create a utility that automatically registers plugins when schemas are created:
// packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts
export interface SchemaPluginConfig {
id: string;
name: string;
description?: string;
icon?: string;
}
/**
* Convert a schema definition into a plugin definition
*/
export function createPluginFromSchema(
schema: SchemaDefinition,
schemaId: string
): PluginDefinition {
return {
id: schemaId,
name: schema.content || schemaId,
config: {
slashCommands: [
{
id: schemaId,
name: schema.content || schemaId,
description: schema.properties?.description || `Create ${schema.content || schemaId}`,
contentTemplate: '',
nodeType: schemaId,
priority: 50 // Lower than core types (100), higher than user commands (0)
}
],
canHaveChildren: true,
canBeChild: true
},
node: {
// Use generic custom entity component
lazyLoad: () => import('../design/components/custom-entity-node.svelte')
}
// No custom viewer - falls back to BaseNodeViewer
};
}
/**
* Register a schema as a plugin immediately
*/
export async function registerSchemaPlugin(schemaId: string): Promise<void> {
const schema = await schemaService.getSchema(schemaId);
// Don't register core types (already registered in core-plugins.ts)
if (schema.properties?.is_core === true) {
return;
}
const plugin = createPluginFromSchema(schema, schemaId);
pluginRegistry.register(plugin);
console.log(`[SchemaPluginLoader] Registered plugin for custom entity: ${schemaId}`);
}
/**
* Unregister a schema plugin
*/
export function unregisterSchemaPlugin(schemaId: string): void {
pluginRegistry.unregister(schemaId);
console.log(`[SchemaPluginLoader] Unregistered plugin: ${schemaId}`);
}
Integration Points
1. Schema Creation Flow (packages/desktop-app/src-tauri/src/commands/schemas.rs):
Add Tauri event emission when schemas are created:
#[tauri::command]
pub async fn create_schema(
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
schema_id: String,
fields: Vec<SchemaField>,
) -> Result<SchemaDefinition, String> {
let schema = state.schema_service.create_schema(&schema_id, fields).await
.map_err(|e| e.to_string())?;
// Emit event for frontend to register plugin
app_handle.emit_all("schema:created", SchemaCreatedPayload {
schema_id: schema_id.clone(),
is_core: schema.properties.is_core
}).map_err(|e| e.to_string())?;
Ok(schema)
}
2. Frontend Event Listener (App initialization or root layout):
// packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts (continued)
/**
* Initialize schema plugin auto-registration
* Call this once during app startup
*/
export async function initializeSchemaPluginSystem(): Promise<void> {
// Register existing custom entity schemas on startup
await registerExistingSchemas();
// Listen for new schema creation events
await listen<SchemaCreatedEvent>('schema:created', async (event) => {
const { schema_id, is_core } = event.payload;
if (!is_core) {
await registerSchemaPlugin(schema_id);
}
});
// Listen for schema deletion events
await listen<SchemaDeletedEvent>('schema:deleted', (event) => {
unregisterSchemaPlugin(event.payload.schema_id);
});
}
/**
* Register all existing custom entity schemas on app startup
*/
async function registerExistingSchemas(): Promise<void> {
const schemas = await schemaService.getAllSchemas();
for (const schema of schemas) {
if (schema.properties?.is_core === false) {
await registerSchemaPlugin(schema.id);
}
}
console.log(`[SchemaPluginLoader] Registered ${schemas.length} custom entity schemas`);
}
3. App Initialization (packages/desktop-app/src/routes/+layout.svelte):
<script lang="ts">
import { onMount } from 'svelte';
import { initializeSchemaPluginSystem } from '$lib/plugins/schema-plugin-loader';
onMount(async () => {
await initializeSchemaPluginSystem();
});
</script>
Acceptance Criteria
Functional Requirements
Technical Requirements
Testing Requirements
Implementation Details
Custom Entity Node Component
Create a generic component for all custom entity types:
<!-- packages/desktop-app/src/lib/design/components/custom-entity-node.svelte -->
<script lang="ts">
import BaseNode from './base-node.svelte';
import SchemaPropertyForm from '$lib/components/property-forms/schema-property-form.svelte';
let {
nodeId,
nodeType,
content,
properties
} = $props();
// Load schema for this entity type
let schema = $state(null);
$effect(async () => {
try {
schema = await schemaService.getSchema(nodeType);
} catch (error) {
console.error(`[CustomEntityNode] Failed to load schema for ${nodeType}:`, error);
}
});
</script>
<div class="custom-entity-node">
<BaseNode {nodeId} {nodeType} {content} {properties} />
{#if schema}
<SchemaPropertyForm {nodeId} {nodeType} />
{/if}
</div>
<style>
.custom-entity-node {
/* Custom entity styling */
}
</style>
Icon Handling
For MVP, use generic icon (text-circle fallback). Future enhancement can add custom icons via schema properties.
Priority Sorting
Custom entity slash commands are assigned priority 50:
- Core types (text, task, etc.): priority
100 (appear first)
- Custom entities: priority
50 (appear in middle)
- User-defined commands: priority
0 (appear last)
Testing Plan
Unit Tests
// schema-plugin-loader.test.ts
describe('createPluginFromSchema', () => {
it('converts schema to plugin definition', () => {
const schema = {
id: 'invoice',
content: 'Invoice',
properties: {
is_core: false,
description: 'Track invoices with amount and vendor'
}
};
const plugin = createPluginFromSchema(schema, 'invoice');
expect(plugin.id).toBe('invoice');
expect(plugin.name).toBe('Invoice');
expect(plugin.config.slashCommands).toHaveLength(1);
expect(plugin.config.slashCommands[0].nodeType).toBe('invoice');
});
it('skips core types', async () => {
const coreSchema = {
properties: { is_core: true }
};
await registerSchemaPlugin('task');
// Should not register (core type)
expect(pluginRegistry.hasPlugin('task')).toBe(true); // Already registered by core
});
});
Integration Tests
describe('Schema plugin auto-registration', () => {
it('registers plugin when schema created', async () => {
// Create schema
await schemaService.createSchema('invoice', fields);
// Wait for event processing
await waitFor(() => pluginRegistry.hasPlugin('invoice'));
// Verify slash command
const commands = slashCommandService.getCommands();
expect(commands.find(c => c.id === 'invoice')).toBeDefined();
});
it('unregisters plugin when schema deleted', async () => {
await schemaService.createSchema('invoice', fields);
await waitFor(() => pluginRegistry.hasPlugin('invoice'));
await schemaService.deleteSchema('invoice');
await waitFor(() => !pluginRegistry.hasPlugin('invoice'));
const commands = slashCommandService.getCommands();
expect(commands.find(c => c.id === 'invoice')).toBeUndefined();
});
});
Dependencies
Performance Considerations
- Startup overhead: ~10-50ms to register existing schemas (acceptable)
- Runtime registration: <5ms per schema (negligible)
- Memory usage: ~1KB per custom entity plugin (negligible)
Optimization opportunity (future):
- Cache schema-to-plugin conversions
- Lazy load CustomEntityNode component only when first used
Migration Notes
No migration required - This is additive functionality. Existing core plugins continue to work as-is.
Deployment notes:
Documentation Updates
Success Metrics
- Custom entity slash commands appear <100ms after schema creation
- Zero manual plugin registration required
- 100% hot-reload success rate (no restarts needed)
- No performance degradation in slash command dropdown
Labels: foundation, frontend, typescript, plugin-system
Priority: High (critical for custom entity feature)
Estimated effort: 6-8 hours
Parent Issue
Part of Epic #445: Custom Entity Types - User-Defined Schemas with Natural Language Creation
Problem
When a user creates a custom entity schema (e.g., "invoice"), the schema is stored in the database but does not automatically become available as a slash command. Users must manually register a plugin, which defeats the purpose of dynamic entity creation.
Current workflow (manual):
Desired workflow (automatic):
Solution
Implement immediate plugin registration when schemas are created. This leverages the existing hot-reloading capabilities discovered in the plugin registry.
Architecture
Key Discovery: The plugin registry already supports runtime registration without restart!
pluginRegistry.register()can be called anytimeSlashCommandService.getCommands()queries registry fresh on every/triggerImplementation Approach: Schema-to-Plugin Converter
Create a utility that automatically registers plugins when schemas are created:
Integration Points
1. Schema Creation Flow (
packages/desktop-app/src-tauri/src/commands/schemas.rs):Add Tauri event emission when schemas are created:
2. Frontend Event Listener (App initialization or root layout):
3. App Initialization (
packages/desktop-app/src/routes/+layout.svelte):Acceptance Criteria
Functional Requirements
/type-namesyntaxis_core: true)Technical Requirements
schema-plugin-loader.tsutility createdcreatePluginFromSchema()converts schemas to pluginsregisterSchemaPlugin()handles registrationinitializeSchemaPluginSystem()sets up event listenersCustomEntityNodecomponent used for all custom typesTesting Requirements
createPluginFromSchema()conversion logic/invoice→ node createdImplementation Details
Custom Entity Node Component
Create a generic component for all custom entity types:
Icon Handling
For MVP, use generic icon (text-circle fallback). Future enhancement can add custom icons via schema properties.
Priority Sorting
Custom entity slash commands are assigned priority
50:100(appear first)50(appear in middle)0(appear last)Testing Plan
Unit Tests
Integration Tests
Dependencies
Performance Considerations
Optimization opportunity (future):
Migration Notes
No migration required - This is additive functionality. Existing core plugins continue to work as-is.
Deployment notes:
Documentation Updates
Success Metrics
Labels:
foundation,frontend,typescript,plugin-systemPriority: High (critical for custom entity feature)
Estimated effort: 6-8 hours