Skip to content

Slash command creates custom entity nodes inline - #972

Merged
malibio merged 5 commits into
mainfrom
feature/issue-971-slash-command-custom-types
Mar 17, 2026
Merged

malibio merged 5 commits into
mainfrom
feature/issue-971-slash-command-custom-types

Conversation

@malibio

@malibio malibio commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator

Closes #971

- 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) <noreply@anthropic.com>
@malibio

malibio commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review Summary

Review Type: Initial Review

This PR delivers a well-scoped, coherent set of changes across backend validation, frontend node rendering, and the plugin/store layers. The implementation follows established patterns (TaskNode open button, sharedNodeStore reactivity) and the commit message accurately describes the change surface. The change is a definite net improvement.

Two issues require attention before merge: a backend gap where `handle_create_schema` and `handle_update_schema` never pass `title_template` through to the node properties (meaning the validation added in `behaviors/mod.rs` can never actually trigger from the MCP path), and a template deduplication problem in `create_custom_schema` that silently allows duplicate field entries for repeated tokens.


Requirements Check

  • ✅ `validate_schema_node()` rejects `title_template` referencing undefined fields with a clear error message
  • ❌ MCP `create_schema` and `update_schema` return a proper error on validation failure — see Critical Set up Tauri + Svelte project structure #1 below: neither handler passes `title_template` into the node properties, so validation is never invoked from this path
  • ✅ Custom schema types (`isCore: false`) appear in the `/` slash command picker (via schema-plugin-loader auto-registration)
  • ✅ Selecting a custom type without `title_template` creates a child node with editable `content`, behaves like a task node
  • ✅ Selecting a custom type with `title_template` creates a child node with `content = 'Untitled'`
  • ✅ Node with `title_template`: shows raw template string as read-only placeholder when title is empty/Untitled
  • ✅ Node with `title_template`: shows interpolated title reactively once properties are filled
  • ✅ Node with `title_template`: `content` field is not editable inline
  • ✅ Inline node has an "open" button that opens the node in a new tab in the other pane
  • ✅ Tests added for new backend validation logic
  • ✅ `contentTemplate: 'Untitled'` assertion updated in schema-plugin-loader tests

Findings

Critical Issues

[Critical/Blocker] `packages/core/src/mcp/handlers/schema.rs` — `title_template` never reaches the stored node, defeating its own validation

`handle_create_schema` builds the properties JSON at line ~165 without including `titleTemplate`:

```rust
let properties = serde_json::json!({
"isCore": false,
"schemaVersion": 1,
"description": &description_text,
"fields": &namespaced_fields,
"relationships": &relationships
// titleTemplate is absent — it will never be stored or validated
});
```

`CreateSchemaParams` also does not declare a `title_template` / `title_template` field, so even if an MCP caller sends one it is silently dropped. The same problem exists in `handle_update_schema` around line ~508, which rebuilds properties without forwarding `title_template`.

The acceptance criterion says "MCP create_schema and update_schema return a proper error response when validation fails". The new cross-validation in `validate_schema_node()` is correct, but it can only fire when a schema node is constructed with the field present. The MCP path never puts it there, so the validation is unreachable from MCP. The fix is to add `title_template: Option` to `CreateSchemaParams` (and the update params struct) and include it in the `properties` JSON when present.


[Critical/Blocker] `packages/core/src/services/node_service.rs` — `create_custom_schema` helper produces duplicate fields for repeated tokens

The new token-extraction loop in the test helper (lines ~10597–10613) does not deduplicate field names. A template like `"{first_name} {first_name}"` produces two `first_name` fields in the `fields` array. This will cause the schema to be rejected by any future uniqueness validation and produces misleading test setups today. The fix is a simple contains-check before pushing:

```rust
if !field_name.is_empty() && !fields.iter().any(|f| f["name"] == field_name) {
fields.push(...);
}
```

While this is a test helper rather than production code, it is shared across multiple tests and incorrect shared helpers are a common source of silent false-positives.


Suggested Improvements

[Improvement] `packages/desktop-app/src/lib/design/components/custom-entity-node.svelte` — `BaseNode` event forwarding is duplicated verbatim

The two `` branches in the `{#if hasTitleTemplate}` block are identical except for the `customViewContent` prop. This violates DRY and will require maintaining both copies in sync every time new events are added to BaseNode. A single `` with a conditional `customViewContent` prop would reduce the template by ~25 lines and eliminate the maintenance risk:

```svelte
<BaseNode
{nodeId}
bind:nodeType
bind:content
{children}
customViewContent={hasTitleTemplate && displayContent != null ? titleTemplateSnippet : undefined}
on:createNewNode={forwardEvent('createNewNode')}
...
/>
```

[Improvement] `packages/desktop-app/src/lib/design/components/custom-entity-node.svelte` — `typingTimer` not cleared on component destroy

The `typingTimer` timeout is set via `handleTypingStart()` but never cleared when the component unmounts. In Svelte 5 the correct pattern is `$effect(() => () => { if (typingTimer) clearTimeout(typingTimer); })` to register an on-destroy cleanup. Without it, if the node is removed from the DOM while the timer is pending, the callback fires against a stale `isTyping` reference. This is a minor leak but matches the same pattern TaskNode should follow.

[Improvement] `packages/core/src/behaviors/mod.rs` — cross-validation is order-dependent on the existing syntax pass

The `defined_fields` HashSet is built once before the byte-scanning loop, then the field-name lookup is interleaved inside the loop. This is correct but subtly brittle: the cross-validation only reaches the `defined_fields.contains()` check after the syntax checks pass for that token. If a future developer reorders the early-return conditions inside the loop, they could accidentally skip the cross-check. Consider extracting a small `validate_field_names(template, defined_fields)` helper after the syntax loop completes — separating syntax validation from semantic validation makes both easier to test and modify independently.


Nitpicks

  • Nit: `packages/desktop-app/src/lib/stores/schemas.ts` — `SIDENAV_CORE_TYPES` is a hardcoded allowlist with a single entry (`'task'`). The inline comment explains the intent well. Consider whether this set belongs in a constants file or configuration rather than the store module as the set grows.

  • Nit: `packages/desktop-app/src/lib/design/components/custom-entity-node.svelte` line 122 — `const showEntityHeader = $derived(true)` is always-true. This is dead reactive overhead; a plain `const showEntityHeader = true` suffices (or remove the variable entirely and use the literal in the template).

  • Nit: `packages/desktop-app/src/lib/design/components/custom-entity-node.svelte` — the open button label text is the literal string `open` (lowercase). The task-node pattern uses an icon instead. Consider either an icon for visual consistency or `Open` (capitalized) to match button conventions.


Recommendation: REQUEST CHANGES

The two critical issues need resolution before merge. The backend gap (MCP never passing `title_template`) means the stated acceptance criterion about MCP returning validation errors is not met, and the deduplication gap in the test helper produces unreliable test scaffolding. The frontend improvements are non-blocking but the duplicated BaseNode block is worth fixing in the same pass since it is cheaply addressed.

🔴 Critical: Add title_template to MCP create_schema / update_schema
- Add title_template: Option<String> 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 <BaseNode> 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) <noreply@anthropic.com>
@malibio

malibio commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator Author

Review Addressed

All findings from the code review have been fixed.

✅ 🔴 Critical: title_template unreachable via MCP

Added title_template: Option<String> to both CreateSchemaParams and UpdateSchemaParams. The value is now included as titleTemplate in the properties JSON written to the store. For handle_update_schema (which uses update_node_unchecked and bypasses the behavior pipeline), I added an explicit SchemaNodeBehavior::validate_schema_node() call before saving so cross-validation fires consistently. Updated both MCP tool input schemas to document the parameter.

✅ 🔴 Critical: Duplicate field names in test helper

Added a HashSet in create_custom_schema to deduplicate token names extracted from the template before building the fields array.

✅ 🟡 Important: Duplicate BaseNode blocks

Collapsed the {#if hasTitleTemplate}/{:else} branches into a single <BaseNode> that passes customViewContent conditionally. Eliminates ~20 lines of duplicated event wiring.

✅ 🟡 Important: typingTimer not cleared on destroy

Added onDestroy(() => clearTimeout(typingTimer)) to prevent stale callbacks after unmount.

✅ 🟢 showEntityHeader = $derived(true)

Replaced with a plain const showEntityHeader = true.

⏭️ Skipped: SIDENAV_CORE_TYPES extraction — out of scope, fine as-is for now

malibio and others added 2 commits March 17, 2026 21:43
Show syntax + 3 concrete examples (customer, invoice, contact) in both
create_schema and update_schema tool schemas so AI agents understand
the {field_name} token format without having to guess.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Covers all title_template edge cases end-to-end through handle_create_schema
and handle_update_schema against a real NodeService/SurrealStore:

- create with valid template → succeeds
- create with template referencing undefined field → rejected
- create without template → succeeds
- update: add valid template to existing schema → succeeds
- update: add template referencing undefined field → rejected
- update: remove field still referenced by existing template → rejected
- update: remove unrelated field while template exists → succeeds
- update: clear template (empty string) then remove field → succeeds

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@malibio

malibio commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review Summary — Re-Review

Review Type: Re-Review (addressing findings from review-20260317-213612)

Three commits landed since the last review tag:

  1. 55b5ba1 — Address review: MCP title_template support, dedup fields, cleanup
  2. e63a25e — docs(mcp): enrich title_template descriptions with multiple examples
  3. 24338928 — test(mcp): add integration tests for title_template handler validation

Both Critical/Blocker issues are resolved. Both Important improvements are resolved. All three commits are well-scoped and cohesive. The test suite added in the third commit is particularly thorough. The change is approved.


Verification of Previous Findings

[Critical/Blocker — RESOLVED] title_template never reached the stored node via MCP

CreateSchemaParams and UpdateSchemaParams now each carry title_template: Option<String>. The field is inserted as "titleTemplate" into the properties JSON in both handle_create_schema (line ~178) and handle_update_schema (line ~522). The explicit SchemaNodeBehavior::validate_schema_node() call in handle_update_schema before update_node_unchecked (lines 528–538) ensures cross-validation fires consistently on the update path, which was the core gap. Confirmed correct.

[Critical/Blocker — RESOLVED] Duplicate field names in create_custom_schema test helper

The HashSet guard (seen.insert(field_name.to_string())) is in place. The fix is minimal and correct.

[Improvement — RESOLVED] Duplicate BaseNode blocks

The {#if hasTitleTemplate}/{:else} branches are collapsed into a single <BaseNode> with a conditional customViewContent prop. The result is ~20 fewer lines and a single event-wiring surface to maintain.

[Improvement — RESOLVED] typingTimer not cleared on destroy

onDestroy(() => clearTimeout(typingTimer)) is in place.

[Nit — RESOLVED] showEntityHeader = $derived(true)

Replaced with a plain const showEntityHeader = true.

[Nit — ACKNOWLEDGED / SKIPPED] SIDENAV_CORE_TYPES extraction

The author explicitly noted this as out of scope. Acceptable for now.


New Issues in the Three Commits

Suggested Improvements

[Improvement] packages/core/src/mcp/handlers/schema.rs line 512 — Option::or makes title_template irremovable once set

The merge strategy params.title_template.or(schema.title_template) treats None in the request as "keep existing". That is correct for the "omit → preserve" case, but it means there is no way for an MCP caller to explicitly remove a previously set title_template. The only documented workaround is passing an empty string "", which the test at line 237–251 exercises. The comment in that test acknowledges the limitation: "Option with serde default can't distinguish 'omit' from 'clear'".\n\nThis is a real API design gap. As shipped, an AI agent using the MCP tool has no way to express "delete the template" without knowing the empty-string workaround, which is nowhere in the tool description. The tools.rs description for update_schema title_template (line ~1343) says "Set or update the title template" but gives no hint about how to clear it. Given that clearing a template unblocks field removal (the test at line 212 exists precisely for this reason), this is a usage scenario that will be hit in practice.\n\nThe fix is a one-line addition to the update_schema tool description: append ", or pass an empty string to remove the existing template" to the current description text.

[Improvement] packages/core/src/mcp/handlers/schema_test.rs — module is included twice via separate #[cfg(test)] blocks

In schema.rs the file is mounted with:

#[cfg(test)]
#[path = "schema_test.rs"]
mod schema_test;

#[cfg(test)]
mod tests {
    ...
}

The #[path] module directive is correctly scoped to cfg(test), which is fine. However, the two #[cfg(test)] attributes are separate items rather than a single block, which is the conventional pattern. This is minor but note that schema_test uses use super::* while the inline tests module also uses use super::*. They both import from the same parent scope and will compile cleanly. No functional issue — purely a style observation worth keeping in mind if more external test files are added.

Nitpicks

  • Nit: packages/core/src/mcp/handlers/schema_test.rs line 17 — let mut store = Arc::new(...) declares store as mut but only the Arc is mutated (via the NodeService::new(&mut store) signature). If NodeService::new takes &mut Arc<SurrealStore> to perform interior mutation, this is correct. It is a slightly unusual signature — a future reader might wonder why an Arc is &mut. No action needed; just noting it may warrant a clarifying comment if the pattern appears often.

  • Nit: The create_base_schema helper in schema_test.rs (line ~103) calls .unwrap() on the result without any context message. Given that this is setup code whose failure would obscure the real test intent, a .expect("create_base_schema: handle_create_schema should succeed") would make test failures easier to diagnose.

  • Nit: packages/core/src/mcp/handlers/tools.rs — The description string added for title_template in both create_schema and update_schema is very long (single-line strings > 200 chars). This is consistent with the existing style in tools.rs, so no change is needed; flagging only for awareness if a tools-description cleanup pass happens in the future.


Final Recommendation: APPROVE

Both blockers are resolved correctly. The improvement commits (doc enrichment, integration test coverage) go beyond what was asked and meaningfully strengthen the feature. The one new Improvement finding (undocumentable title_template clear) is low-severity and easily addressed with a one-line description update — it does not block merge.

…onomics

- Document that passing empty string \"\" clears an existing title_template
  in the update_schema MCP tool description (title_template is irremovable
  without this hint since omitting the field preserves the existing value)
- Replace .unwrap() with .expect(context) throughout schema_test.rs so test
  failures point to the cause rather than a bare line number
- Add comment explaining why create_test_service uses `let mut store =
  Arc::new(...)` (NodeService::new requires &mut Arc<SurrealStore>)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@malibio
malibio merged commit 01f1dab into main Mar 17, 2026
@malibio
malibio deleted the feature/issue-971-slash-command-custom-types branch March 17, 2026 20:55
malibio added a commit that referenced this pull request Jun 22, 2026
* feat: slash command creates custom entity nodes inline (closes #971)

- 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) <noreply@anthropic.com>

* Address review: MCP title_template support, dedup fields, cleanup

🔴 Critical: Add title_template to MCP create_schema / update_schema
- Add title_template: Option<String> 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 <BaseNode> 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) <noreply@anthropic.com>

* docs(mcp): enrich title_template descriptions with multiple examples

Show syntax + 3 concrete examples (customer, invoice, contact) in both
create_schema and update_schema tool schemas so AI agents understand
the {field_name} token format without having to guess.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* test(mcp): add integration tests for title_template handler validation

Covers all title_template edge cases end-to-end through handle_create_schema
and handle_update_schema against a real NodeService/SurrealStore:

- create with valid template → succeeds
- create with template referencing undefined field → rejected
- create without template → succeeds
- update: add valid template to existing schema → succeeds
- update: add template referencing undefined field → rejected
- update: remove field still referenced by existing template → rejected
- update: remove unrelated field while template exists → succeeds
- update: clear template (empty string) then remove field → succeeds

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Address re-review: document title_template clearing, improve test ergonomics

- Document that passing empty string \"\" clears an existing title_template
  in the update_schema MCP tool description (title_template is irremovable
  without this hint since omitting the field preserves the existing value)
- Replace .unwrap() with .expect(context) throughout schema_test.rs so test
  failures point to the cause rather than a bare line number
- Add comment explaining why create_test_service uses `let mut store =
  Arc::new(...)` (NodeService::new requires &mut Arc<SurrealStore>)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slash command creates custom entity nodes inline

1 participant