From d3e119bdf67334b25a091271fd7180a46dd45d1b Mon Sep 17 00:00:00 2001 From: Michael Libio Date: Tue, 17 Mar 2026 21:32:49 +0100 Subject: [PATCH 1/5] feat: slash command creates custom entity nodes inline (closes #971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix contentTemplate: '' → 'Untitled' in createPluginFromSchema() so custom entity nodes start with a name instead of being empty - Add titleTemplate?: string to SchemaNode frontend interface so computed titles are accessible in CustomEntityNode - Update CustomEntityNode to show computed title (or raw template as placeholder) in read-only view mode when schema has title_template; add "open" button on hover (same pattern as TaskNode) so entity can be opened in the other pane - Use sharedNodeStore + structureTree in CustomEntityNode for cross-pane reactivity (matches TaskNode pattern) - Backend: add cross-validation in validate_schema_node() — every {token} in title_template must reference a field defined in schema.fields; undefined tokens return ValidationFailed with a descriptive error - Update existing test_title_template_valid to include fields in schema - Add tests: test_title_template_undefined_field_rejected, test_title_template_defined_field_accepted (behaviors/mod.rs) - Update create_custom_schema test helper to auto-generate fields from template tokens so existing node_service title_template tests continue passing - Update contentTemplate assertion in schema-plugin-loader.test.ts; add dedicated test for contentTemplate: 'Untitled' - Narrow builtInSchemas in schemas.ts to SIDENAV_CORE_TYPES (only 'task') so structural node types don't clutter the sidenav Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/core/src/behaviors/mod.rs | 90 +++++++- packages/core/src/services/node_service.rs | 29 ++- .../components/custom-entity-node.svelte | 200 +++++++++++++++++- .../src/lib/plugins/schema-plugin-loader.ts | 2 +- .../desktop-app/src/lib/stores/schemas.ts | 11 +- .../desktop-app/src/lib/types/schema-node.ts | 4 + .../plugins/schema-plugin-loader.test.ts | 12 +- 7 files changed, 333 insertions(+), 15 deletions(-) diff --git a/packages/core/src/behaviors/mod.rs b/packages/core/src/behaviors/mod.rs index 61ae8837b..1a9205c8d 100644 --- a/packages/core/src/behaviors/mod.rs +++ b/packages/core/src/behaviors/mod.rs @@ -1053,6 +1053,8 @@ impl SchemaNodeBehavior { // Validate title_template syntax: every '{' must have a matching '}' and a non-empty field name if let Some(template) = &schema.title_template { + let defined_fields: HashSet<&str> = + schema.fields.iter().map(|f| f.name.as_str()).collect(); let bytes = template.as_bytes(); let mut i = 0; while i < bytes.len() { @@ -1071,6 +1073,13 @@ impl SchemaNodeBehavior { "title_template contains an empty '{}' placeholder".to_string(), )); } + // Cross-check: token must reference a defined field + if !defined_fields.contains(field_name) { + return Err(NodeValidationError::InvalidProperties(format!( + "title_template references undefined field '{}' — add it to schema.fields", + field_name + ))); + } i += 1 + end + 1; // skip past '}' continue; } @@ -2902,7 +2911,10 @@ mod tests { "schemaVersion": 1, "description": "", "titleTemplate": "{first_name} {last_name}", - "fields": [], + "fields": [ + {"name": "first_name", "type": "string", "protection": "user", "indexed": false}, + {"name": "last_name", "type": "string", "protection": "user", "indexed": false} + ], "relationships": [] }), ); @@ -2991,6 +3003,82 @@ mod tests { ); } + #[test] + fn test_title_template_undefined_field_rejected() { + use crate::models::SchemaNode; + let behavior = SchemaNodeBehavior; + + // title_template references a field "nonexistent" that is not in schema.fields + let node = Node::new( + "schema".to_string(), + "Customer".to_string(), + json!({ + "isCore": false, + "schemaVersion": 1, + "description": "Customer schema", + "fields": [ + { + "name": "first_name", + "type": "string", + "protection": "user", + "indexed": false + } + ], + "relationships": [], + "titleTemplate": "{nonexistent}" + }), + ); + let schema = SchemaNode::from_node(node).unwrap(); + let result = behavior.validate_schema_node(&schema); + assert!( + result.is_err(), + "Undefined field in title_template should fail validation" + ); + assert!(matches!( + result, + Err(NodeValidationError::InvalidProperties(ref msg)) + if msg.contains("undefined field") && msg.contains("nonexistent") + )); + } + + #[test] + fn test_title_template_defined_field_accepted() { + use crate::models::SchemaNode; + let behavior = SchemaNodeBehavior; + + // title_template references fields that exist in schema.fields + let node = Node::new( + "schema".to_string(), + "Customer".to_string(), + json!({ + "isCore": false, + "schemaVersion": 1, + "description": "Customer schema", + "fields": [ + { + "name": "first_name", + "type": "string", + "protection": "user", + "indexed": false + }, + { + "name": "last_name", + "type": "string", + "protection": "user", + "indexed": false + } + ], + "relationships": [], + "titleTemplate": "{first_name} {last_name}" + }), + ); + let schema = SchemaNode::from_node(node).unwrap(); + assert!( + behavior.validate_schema_node(&schema).is_ok(), + "title_template referencing defined fields should pass validation" + ); + } + // ========================================================================= // TaskNode from_node Format Tests // ========================================================================= diff --git a/packages/core/src/services/node_service.rs b/packages/core/src/services/node_service.rs index 2c64566af..0b19d9663 100644 --- a/packages/core/src/services/node_service.rs +++ b/packages/core/src/services/node_service.rs @@ -10588,8 +10588,33 @@ mod tests { mod title_template_tests { use super::*; - /// Helper: create a custom schema with the given title_template + /// Helper: create a custom schema with the given title_template. + /// Automatically adds string fields for every {token} found in the template, + /// satisfying the cross-validation that tokens must reference defined fields. async fn create_custom_schema(service: &NodeService, type_id: &str, title_template: &str) { + // Extract field names from template tokens like {first_name} + let mut fields: Vec = vec![]; + let bytes = title_template.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' { + if let Some(end) = bytes[i + 1..].iter().position(|&c| c == b'}') { + let field_name = &title_template[i + 1..i + 1 + end]; + if !field_name.is_empty() { + fields.push(json!({ + "name": field_name, + "type": "string", + "protection": "user", + "indexed": false + })); + } + i += 1 + end + 1; + continue; + } + } + i += 1; + } + let schema_node = Node::new_with_id( type_id.to_string(), "schema".to_string(), @@ -10599,7 +10624,7 @@ mod tests { "schemaVersion": 1, "description": format!("Test schema for {}", type_id), "titleTemplate": title_template, - "fields": [], + "fields": fields, "relationships": [] }), ); diff --git a/packages/desktop-app/src/lib/design/components/custom-entity-node.svelte b/packages/desktop-app/src/lib/design/components/custom-entity-node.svelte index 56049f011..a001c4b27 100644 --- a/packages/desktop-app/src/lib/design/components/custom-entity-node.svelte +++ b/packages/desktop-app/src/lib/design/components/custom-entity-node.svelte @@ -15,6 +15,8 @@ - Visual distinction via left border with custom entity color - Error handling with helpful fallbacks for missing schemas - Custom icon support from schema metadata + - "Open" button to view entity in dedicated pane (like task nodes) + - Read-only title display when schema has title_template ## Architecture @@ -43,17 +45,38 @@ --> -
+
{#if showEntityHeader && schema && !isLoadingSchema && entityName}
@@ -119,12 +204,54 @@ {/if} - + {#if hasTitleTemplate && displayContent != null} + + { + handleTypingStart(); + dispatch('contentChanged', e.detail); + }} + on:indentNode={forwardEvent('indentNode')} + on:outdentNode={forwardEvent('outdentNode')} + on:navigateArrow={forwardEvent('navigateArrow')} + on:combineWithPrevious={forwardEvent('combineWithPrevious')} + on:deleteNode={forwardEvent('deleteNode')} + on:focus={forwardEvent('focus')} + on:blur={forwardEvent('blur')} + on:nodeReferenceSelected={forwardEvent('nodeReferenceSelected')} + on:slashCommandSelected={forwardEvent('slashCommandSelected')} + on:nodeTypeChanged={forwardEvent('nodeTypeChanged')} + /> + {:else} + + { + handleTypingStart(); + dispatch('contentChanged', e.detail); + }} + on:indentNode={forwardEvent('indentNode')} + on:outdentNode={forwardEvent('outdentNode')} + on:navigateArrow={forwardEvent('navigateArrow')} + on:combineWithPrevious={forwardEvent('combineWithPrevious')} + on:deleteNode={forwardEvent('deleteNode')} + on:focus={forwardEvent('focus')} + on:blur={forwardEvent('blur')} + on:nodeReferenceSelected={forwardEvent('nodeReferenceSelected')} + on:slashCommandSelected={forwardEvent('slashCommandSelected')} + on:nodeTypeChanged={forwardEvent('nodeTypeChanged')} + /> + {/if} {#if isLoadingSchema} @@ -140,10 +267,29 @@ {:else if schema && nodeType} {/if} + + +
+{#snippet titleTemplateSnippet()} + {displayContent} +{/snippet} + diff --git a/packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts b/packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts index d5e695e5a..1912d7593 100644 --- a/packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts +++ b/packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts @@ -129,7 +129,7 @@ export function createPluginFromSchema(schema: SchemaNode): PluginDefinition { id: schemaId, name: displayName, description: description || `Create ${displayName}`, - contentTemplate: '', + contentTemplate: 'Untitled', nodeType: schemaId, priority: PLUGIN_PRIORITIES.CUSTOM_ENTITY } diff --git a/packages/desktop-app/src/lib/stores/schemas.ts b/packages/desktop-app/src/lib/stores/schemas.ts index 0199efbdc..41255e962 100644 --- a/packages/desktop-app/src/lib/stores/schemas.ts +++ b/packages/desktop-app/src/lib/stores/schemas.ts @@ -31,8 +31,15 @@ async function loadSchemas(): Promise { } } -// Derived: all built-in (core) schemas shown in sidenav -export const builtInSchemas = derived(_schemas, ($s) => $s.filter((s) => s.isCore)); +// Core schema IDs that are user-queryable and should appear in the sidenav. +// Structural/inline types (text, date, header, code-block, etc.) are excluded — +// they are node content primitives, not entity types users browse or filter. +const SIDENAV_CORE_TYPES = new Set(['task']); + +// Derived: core schemas shown in sidenav (only user-queryable ones like "task") +export const builtInSchemas = derived(_schemas, ($s) => + $s.filter((s) => s.isCore && SIDENAV_CORE_TYPES.has(s.id)) +); // Derived: user-created custom schemas export const customSchemas = derived(_schemas, ($s) => $s.filter((s) => !s.isCore)); diff --git a/packages/desktop-app/src/lib/types/schema-node.ts b/packages/desktop-app/src/lib/types/schema-node.ts index 87a6aee5d..d67abe4f2 100644 --- a/packages/desktop-app/src/lib/types/schema-node.ts +++ b/packages/desktop-app/src/lib/types/schema-node.ts @@ -137,6 +137,10 @@ export interface SchemaNode { /** Array of field definitions */ fields: SchemaField[]; + + /** Optional template for computing display title from properties, e.g. "{first_name} {last_name}". + * When set, content is read-only and title is interpolated from schema properties. */ + titleTemplate?: string; } /** diff --git a/packages/desktop-app/src/tests/plugins/schema-plugin-loader.test.ts b/packages/desktop-app/src/tests/plugins/schema-plugin-loader.test.ts index 7581efa5b..3a0e7be64 100644 --- a/packages/desktop-app/src/tests/plugins/schema-plugin-loader.test.ts +++ b/packages/desktop-app/src/tests/plugins/schema-plugin-loader.test.ts @@ -74,7 +74,7 @@ describe('Schema Plugin Loader - createPluginFromSchema()', () => { id: 'invoice', name: 'Sales Invoice', description: 'Sales Invoice', - contentTemplate: '', + contentTemplate: 'Untitled', nodeType: 'invoice', priority: PLUGIN_PRIORITIES.CUSTOM_ENTITY } @@ -150,6 +150,16 @@ describe('Schema Plugin Loader - createPluginFromSchema()', () => { expect(plugin.node?.lazyLoad).toBeInstanceOf(Function); }); + it('should set contentTemplate to Untitled so new nodes start with a name', () => { + const schemaNode = createMockSchemaNode('customer', { + description: 'Customer' + }); + + const plugin = createPluginFromSchema(schemaNode); + + expect(plugin.config.slashCommands[0].contentTemplate).toBe('Untitled'); + }); + it('should set nodeType to schema ID for slash command creation', () => { const schemaNode = createMockSchemaNode('customEntity', { description: 'Custom Entity' From 55b5ba1c1cb22612bd80702206247ccf77077777 Mon Sep 17 00:00:00 2001 From: Michael Libio Date: Tue, 17 Mar 2026 21:41:54 +0100 Subject: [PATCH 2/5] Address review: MCP title_template support, dedup fields, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 Critical: Add title_template to MCP create_schema / update_schema - Add title_template: Option to CreateSchemaParams and UpdateSchemaParams - Include titleTemplate in the properties JSON sent to the node store so it is actually persisted when called via MCP - Explicit SchemaNodeBehavior::validate_schema_node() call in handle_update_schema before saving (update_node_unchecked bypasses the behavior pipeline; this ensures cross-validation fires on update just as it does on create) - Document title_template in the MCP tool input schemas (create_schema, update_schema) 🔴 Critical: Deduplicate field names in create_custom_schema test helper - Use HashSet to skip duplicate token names so repeated {token}s in a template do not produce multiple fields with the same name 🟡 Important: Collapse duplicate BaseNode blocks in CustomEntityNode - Replace {#if hasTitleTemplate}/{:else} with a single that passes customViewContent conditionally; eliminates ~20 lines of duplicated event wiring 🟡 Important: Clear typingTimer on destroy in CustomEntityNode - Add onDestroy(() => clearTimeout(typingTimer)) to prevent stale callbacks after the component is unmounted 🟢 Suggestion: Replace showEntityHeader = $derived(true) with plain const - Derived with constant value adds reactive overhead for no benefit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/core/src/mcp/handlers/schema.rs | 44 ++++++++-- packages/core/src/mcp/handlers/tools.rs | 8 ++ packages/core/src/services/node_service.rs | 7 +- .../components/custom-entity-node.svelte | 80 +++++++------------ 4 files changed, 81 insertions(+), 58 deletions(-) diff --git a/packages/core/src/mcp/handlers/schema.rs b/packages/core/src/mcp/handlers/schema.rs index f1e9897c2..3c5e34987 100644 --- a/packages/core/src/mcp/handlers/schema.rs +++ b/packages/core/src/mcp/handlers/schema.rs @@ -4,9 +4,10 @@ //! Supports both explicit field/relationship definitions and natural language descriptions //! with intelligent type inference. +use crate::behaviors::SchemaNodeBehavior; use crate::mcp::types::MCPError; use crate::models::schema::{EnumValue, SchemaField, SchemaProtectionLevel}; -use crate::models::NodeUpdate; +use crate::models::{Node, NodeUpdate, SchemaNode}; use crate::services::{CreateNodeParams, NodeService}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -44,6 +45,11 @@ pub struct CreateSchemaParams { /// Optional additional constraints for explicit type hints (used with description) #[serde(default)] pub additional_constraints: Option, + /// Optional template for computing display title from field values. + /// Use `{field_name}` tokens that reference fields defined in `fields`. + /// Example: `"{first_name} {last_name}"` for a customer schema. + #[serde(default)] + pub title_template: Option, } /// Additional constraints for schema creation @@ -162,13 +168,16 @@ pub async fn handle_create_schema( .description .clone() .unwrap_or_else(|| format!("Schema for {}", params.name)); - let properties = serde_json::json!({ + let mut properties = serde_json::json!({ "isCore": false, "schemaVersion": 1, "description": &description_text, "fields": &namespaced_fields, "relationships": &relationships }); + if let Some(ref template) = params.title_template { + properties["titleTemplate"] = serde_json::Value::String(template.clone()); + } // Create schema node params let schema_node_params = CreateNodeParams { @@ -251,6 +260,11 @@ pub struct UpdateSchemaParams { /// New description (optional) #[serde(default)] pub description: Option, + /// Set or update the title template. Pass `null` (absent) to leave unchanged. + /// Use `{field_name}` tokens referencing fields defined in the schema. + /// Example: `"{first_name} {last_name}"` + #[serde(default)] + pub title_template: Option, } /// Output for schema update operations @@ -494,14 +508,34 @@ pub async fn handle_update_schema( // Update description if provided let description = params.description.unwrap_or(schema.description); - // Update schema node - let properties = serde_json::json!({ + // Resolve title_template: use new value if provided, otherwise keep existing + let title_template = params.title_template.or(schema.title_template); + + // Build updated properties + let mut properties = serde_json::json!({ "isCore": schema.is_core, - "version": schema.schema_version, + "schemaVersion": schema.schema_version, "description": description, "fields": fields, "relationships": relationships }); + if let Some(ref template) = title_template { + properties["titleTemplate"] = serde_json::Value::String(template.clone()); + } + + // Validate the updated schema before saving (update_node_unchecked bypasses the behavior + // pipeline, so we run SchemaNodeBehavior validation explicitly here) + let temp_node = Node::new( + "schema".to_string(), + description.clone(), + properties.clone(), + ); + let updated_schema = SchemaNode::from_node(temp_node).map_err(|e| { + MCPError::invalid_params(format!("Failed to build schema for validation: {}", e)) + })?; + SchemaNodeBehavior + .validate_schema_node(&updated_schema) + .map_err(|e| MCPError::invalid_params(format!("Schema validation failed: {}", e)))?; let update = NodeUpdate { properties: Some(properties), diff --git a/packages/core/src/mcp/handlers/tools.rs b/packages/core/src/mcp/handlers/tools.rs index ab2d2fd4e..ba137e52a 100644 --- a/packages/core/src/mcp/handlers/tools.rs +++ b/packages/core/src/mcp/handlers/tools.rs @@ -1077,6 +1077,10 @@ fn get_tool_schemas(schema_ids: &[String]) -> Value { "description": "Map of field names to their enum values" } } + }, + "title_template": { + "type": "string", + "description": "Optional template for computing the node display title from field values. Use {field_name} tokens that must match fields defined in 'fields'. Example: '{first_name} {last_name}' for a customer schema. When set, the content area shows the computed title as read-only inline." } }, "required": ["name"] @@ -1335,6 +1339,10 @@ fn get_tool_schemas(schema_ids: &[String]) -> Value { "type": "array", "items": {"type": "string"}, "description": "Names of relationships to remove (soft-delete)" + }, + "title_template": { + "type": "string", + "description": "Set or update the title template. Use {field_name} tokens that must match fields in the schema. Example: '{first_name} {last_name}'. When set, the inline node view shows the computed title as read-only." } }, "required": ["schema_id"] diff --git a/packages/core/src/services/node_service.rs b/packages/core/src/services/node_service.rs index 0b19d9663..bccd20e05 100644 --- a/packages/core/src/services/node_service.rs +++ b/packages/core/src/services/node_service.rs @@ -10589,10 +10589,11 @@ mod tests { use super::*; /// Helper: create a custom schema with the given title_template. - /// Automatically adds string fields for every {token} found in the template, + /// Automatically adds string fields for every unique {token} found in the template, /// satisfying the cross-validation that tokens must reference defined fields. async fn create_custom_schema(service: &NodeService, type_id: &str, title_template: &str) { - // Extract field names from template tokens like {first_name} + // Extract unique field names from template tokens like {first_name} + let mut seen = std::collections::HashSet::new(); let mut fields: Vec = vec![]; let bytes = title_template.as_bytes(); let mut i = 0; @@ -10600,7 +10601,7 @@ mod tests { if bytes[i] == b'{' { if let Some(end) = bytes[i + 1..].iter().position(|&c| c == b'}') { let field_name = &title_template[i + 1..i + 1 + end]; - if !field_name.is_empty() { + if !field_name.is_empty() && seen.insert(field_name.to_string()) { fields.push(json!({ "name": field_name, "type": "string", diff --git a/packages/desktop-app/src/lib/design/components/custom-entity-node.svelte b/packages/desktop-app/src/lib/design/components/custom-entity-node.svelte index a001c4b27..3843ba116 100644 --- a/packages/desktop-app/src/lib/design/components/custom-entity-node.svelte +++ b/packages/desktop-app/src/lib/design/components/custom-entity-node.svelte @@ -45,7 +45,7 @@ -->