From f0dc117b631c4f5a3b5b3afdb8153b6d3325c038 Mon Sep 17 00:00:00 2001 From: mstomar125 <185037323+mstomar125@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:45:53 +0200 Subject: [PATCH 1/3] Remove entity-type slash commands (/ai-chat, /person, user-defined types) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core#2133 established that slash commands are for content primitives plus task; entity types are not slash-creatable. core#2151 documented three surfaces that predated the rule as known exceptions, explicitly not precedent. This retires them: - aiChatNodePlugin and personNodePlugin now register slashCommands: [] while keeping their viewer/reference/schema-form/backspace-guard registrations intact. - createPluginFromSchema no longer generates a slash command for user-defined schema types; the plugin registration still exists for reference/viewer resolution. - PLUGIN_PRIORITIES is removed from schema-plugin-loader.ts — CUSTOM_ENTITY was its only consumer, and CORE/USER_COMMAND were already unreferenced anywhere in the codebase. - Inverted the five schema-plugin-loader.test.ts assertions that expected a generated slash command; updated core-plugins.test.ts and slash-command-service-registry-integration.test.ts command counts (13 -> 11 core commands). - Deleted the now-resolved "known exceptions" paragraph from core-plugins.ts's slash-command doc comment. Verified via an isolated dev:browser + real daemon stack that person, ai-chat, and a fresh user-defined schema (Venue) all still create instances through the sidenav/sidebar surfaces after their slash commands are gone, and that the slash-command dropdown offers only the 11 primitive+task commands. Co-Authored-By: Claude --- .../src/lib/plugins/core-plugins.ts | 35 +++-------- .../src/lib/plugins/schema-plugin-loader.ts | 62 ++++++------------- .../src/tests/plugins/core-plugins.test.ts | 18 +++--- .../plugins/schema-plugin-loader.test.ts | 37 +++++------ ...mmand-service-registry-integration.test.ts | 4 +- 5 files changed, 54 insertions(+), 102 deletions(-) diff --git a/packages/desktop-app/src/lib/plugins/core-plugins.ts b/packages/desktop-app/src/lib/plugins/core-plugins.ts index 0d52c0103..402f09483 100644 --- a/packages/desktop-app/src/lib/plugins/core-plugins.ts +++ b/packages/desktop-app/src/lib/plugins/core-plugins.ts @@ -628,15 +628,9 @@ export const aiChatNodePlugin: PluginDefinition = { // quote-block) it must not absorb a Backspace-merge from the node below it. acceptsContentMerge: false, config: { - slashCommands: [ - { - id: 'ai-chat', - name: 'AI Chat', - description: 'Start an AI conversation', - contentTemplate: '', - nodeType: 'ai-chat' - } - ], + // No slash command — entity types are not slash-creatable. AI chats are created + // via the sidebar's dedicated "AI Chats" section ("+ New chat"). + slashCommands: [], canHaveChildren: false, canBeChild: true }, @@ -659,15 +653,9 @@ export const personNodePlugin: PluginDefinition = { description: 'Create a person node — contact, collaborator, or stakeholder', version: '1.0.0', config: { - slashCommands: [ - { - id: 'person', - name: 'Person', - description: 'Create a person node', - contentTemplate: '', - nodeType: 'person' - } - ], + // No slash command — entity types are not slash-creatable. Person nodes are + // created via the sidenav's type view (SIDENAV_CORE_TYPES → create instance). + slashCommands: [], canHaveChildren: false, canBeChild: true }, @@ -697,14 +685,9 @@ export const personNodePlugin: PluginDefinition = { * records set up deliberately — by the agent, or through the schema/entity surfaces — not * typed in ad hoc mid-outline. They stay reachable: most core entity types are listed in * the sidenav as user-queryable types and open to schema-driven properties forms - * (`SIDENAV_CORE_TYPES`), and the rest reach creation through a surface of their own. - * - * Two surfaces predate this rule and still contradict it: `person` and `ai-chat` carry - * slash commands above, and `createPluginFromSchema` generates one for every user-defined - * schema type. Both are slated for removal — `person` and user-defined types already - * reach creation through the sidenav's type view, and `ai-chat` gets a dedicated sidebar - * section before its command goes — so do not read either as precedent for adding new - * entity-type slash commands. + * (`SIDENAV_CORE_TYPES`), and the rest reach creation through a surface of their own — + * `person` and every user-defined type through the sidenav's type view, `ai-chat` through + * its own dedicated sidebar section. * * `project` therefore has no plugin here at all. If you are here because `/project` seems * to be "missing", it is absent by decision, not by oversight. Note that `project` renders 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 39b47fdb3..626be9751 100644 --- a/packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts +++ b/packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts @@ -1,8 +1,10 @@ /** * Schema Plugin Auto-Registration System * - * Automatically registers custom entity schemas as plugins, enabling immediate - * slash command availability without manual plugin registration or app restart. + * Automatically registers custom entity schemas as plugins, enabling reference + * components and viewer fallback without manual plugin registration or app restart. + * Entity types are not slash-creatable (core or user-defined alike) — creation goes + * through the sidenav's type view instead. * * ## Features * @@ -15,14 +17,12 @@ * ## Architecture * * ``` - * Schema Creation → Plugin Auto-Registration → Slash Command Available + * Schema Creation → Plugin Auto-Registration → Reference/Viewer Available * ↓ ↓ ↓ - * SchemaService createPluginFromSchema() SlashCommandService + * SchemaService createPluginFromSchema() PluginRegistry * ``` * * The plugin registry already supports runtime registration without restart. - * SlashCommandService queries the registry fresh on every `/` trigger, so - * no caching layer blocks dynamic updates. * * ## Usage * @@ -30,8 +30,8 @@ * // Initialize on app startup * await initializeSchemaPluginSystem(); * - * // Custom entities automatically become available - * // User creates "invoice" schema → "/invoice" appears in slash menu + * // Custom entities automatically become available for reference/viewer resolution + * // User creates "invoice" schema → invoice nodes render via BaseNode fallback * ``` * * @see packages/desktop-app/src/lib/plugins/plugin-registry.ts - Plugin registration @@ -46,18 +46,6 @@ import { createLogger } from '$lib/utils/logger'; const log = createLogger('SchemaPluginLoader'); -/** - * Plugin priority constants - * - * Defines the priority order for plugin slash commands in the dropdown. - * Higher priority commands appear first. - */ -export const PLUGIN_PRIORITIES = { - CORE: 100, // Core types (text, task, date, etc.) - CUSTOM_ENTITY: 50, // User-defined custom entity schemas - USER_COMMAND: 0 // User-created custom commands -} as const; - /** * Humanize a schema ID into a readable display name * @@ -95,8 +83,9 @@ export function humanizeSchemaId(id: string): string { /** * Convert a schema node into a plugin definition * - * Creates a minimal plugin that registers the custom entity as a slash command - * and uses the generic CustomEntityNode component for rendering. + * Creates a minimal plugin that registers the custom entity for reference/viewer + * resolution via the BaseNode fallback. No slash command is generated — entity types + * are not slash-creatable; instances are created via the sidenav's type view. * * @param schema - Schema node to convert * @returns Plugin definition ready for registration @@ -124,20 +113,10 @@ export function createPluginFromSchema(schema: SchemaNode): PluginDefinition { description: description || `Create ${displayName}`, version: `${version}.0.0`, // Use schema version as plugin version config: { - slashCommands: [ - { - id: schemaId, - name: displayName, - description: description || `Create ${displayName}`, - // Always start with empty content — the viewer shows the raw titleTemplate as a - // faint placeholder hint when titleTemplate is set, and editable blank otherwise. - contentTemplate: '', - nodeType: schemaId, - priority: PLUGIN_PRIORITIES.CUSTOM_ENTITY, - hasTitleTemplate: !!schema.titleTemplate, - titleTemplate: schema.titleTemplate - } - ], + // No slash command — entity types (core and user-defined alike) are not + // slash-creatable. User-defined types are created via the sidenav's type view + // (customSchemas → handleSchemaClick → create instance). + slashCommands: [], canHaveChildren: true, canBeChild: true } @@ -160,7 +139,7 @@ export function createPluginFromSchema(schema: SchemaNode): PluginDefinition { * ```typescript * // Register an invoice schema * await registerSchemaPlugin('invoice'); - * // "/invoice" now appears in slash command dropdown + * // Invoice nodes now resolve to a reference/viewer via the plugin registry * ``` */ export async function registerSchemaPlugin(schemaId: string): Promise { @@ -199,8 +178,8 @@ export async function registerSchemaPlugin(schemaId: string): Promise { /** * Unregister a schema plugin * - * Removes the plugin from the registry. The slash command will no longer - * appear in the dropdown. + * Removes the plugin from the registry. Reference/viewer resolution for the + * type will no longer be available. * * @param schemaId - ID of the schema to unregister * @@ -208,7 +187,6 @@ export async function registerSchemaPlugin(schemaId: string): Promise { * ```typescript * // Remove invoice plugin * unregisterSchemaPlugin('invoice'); - * // "/invoice" no longer appears in slash menu * ``` */ export function unregisterSchemaPlugin(schemaId: string): void { @@ -233,8 +211,8 @@ export interface InitializationResult { /** * Initialize schema plugin auto-registration system * - * Registers all existing custom entity schemas on startup so they are - * available in the slash command picker immediately on launch. + * Registers all existing custom entity schemas on startup so their + * reference/viewer resolution is available immediately on launch. * * Dynamic registration for schemas created/deleted at runtime is handled * by BrowserSyncService and TauriSyncListener, which call diff --git a/packages/desktop-app/src/tests/plugins/core-plugins.test.ts b/packages/desktop-app/src/tests/plugins/core-plugins.test.ts index 73a5adba7..8f56da8fd 100644 --- a/packages/desktop-app/src/tests/plugins/core-plugins.test.ts +++ b/packages/desktop-app/src/tests/plugins/core-plugins.test.ts @@ -92,7 +92,9 @@ describe('Core Plugins Integration', () => { expect(aiChatNodePlugin.id).toBe('ai-chat'); expect(aiChatNodePlugin.name).toBe('AI Chat'); expect(aiChatNodePlugin.description).toBe('AI conversation node'); - expect(aiChatNodePlugin.config.slashCommands).toHaveLength(1); + // Entity types are not slash-creatable — ai-chat nodes are created via the + // sidebar's dedicated "AI Chats" section ("+ New chat"). + expect(aiChatNodePlugin.config.slashCommands).toHaveLength(0); expect(aiChatNodePlugin.config.canHaveChildren).toBe(false); expect(aiChatNodePlugin.config.canBeChild).toBe(true); // Conversation lives in properties.messages, not .content — must be @@ -103,10 +105,6 @@ describe('Core Plugins Integration', () => { expect(aiChatNodePlugin.viewer).toBeDefined(); expect(aiChatNodePlugin.viewer?.lazyLoad).toBeDefined(); expect(aiChatNodePlugin.reference).toBeDefined(); - - const chatCommand = aiChatNodePlugin.config.slashCommands[0]; - expect(chatCommand.id).toBe('ai-chat'); - expect(chatCommand.nodeType).toBe('ai-chat'); }); it('should have valid reference-only plugins', () => { @@ -166,7 +164,7 @@ describe('Core Plugins Integration', () => { // Note: Logger output is intentionally silenced during tests const stats = registry.getStats(); expect(stats.pluginsCount).toBe(14); // text, header, task, checkbox, date, code-block, quote-block, ordered-list, horizontal-line, table, query, collection, ai-chat, person - expect(stats.slashCommandsCount).toBe(13); // text: 1, header: 3, task: 1, checkbox: 1, code-block: 1, quote-block: 1, ordered-list: 1, horizontal-line: 1, table: 1, ai-chat: 1, person: 1, query: 0 (removed #1919), collection: 0, date: 0 + expect(stats.slashCommandsCount).toBe(11); // text: 1, header: 3, task: 1, checkbox: 1, code-block: 1, quote-block: 1, ordered-list: 1, horizontal-line: 1, table: 1, query: 0 (removed #1919), collection: 0, date: 0, ai-chat: 0, person: 0 (entity types are not slash-creatable) expect(stats.viewersCount).toBe(5); // date, task, collection, query, and ai-chat have custom viewers expect(stats.referencesCount).toBe(14); // all plugins have references }); @@ -176,8 +174,8 @@ describe('Core Plugins Integration', () => { const stats = registry.getStats(); - // text: 1, header: 3, task: 1, checkbox: 1, code-block: 1, quote-block: 1, ordered-list: 1, horizontal-line: 1, table: 1, ai-chat: 1, person: 1, query: 0 (removed #1919), date: 0, collection: 0 = 13 total - expect(stats.slashCommandsCount).toBe(13); + // text: 1, header: 3, task: 1, checkbox: 1, code-block: 1, quote-block: 1, ordered-list: 1, horizontal-line: 1, table: 1, query: 0 (removed #1919), date: 0, collection: 0, ai-chat: 0, person: 0 (entity types are not slash-creatable) = 11 total + expect(stats.slashCommandsCount).toBe(11); }); it('should provide all slash commands with proper inheritance', () => { @@ -185,7 +183,7 @@ describe('Core Plugins Integration', () => { const commands = registry.getAllSlashCommands(); - expect(commands).toHaveLength(13); // text, header1-3, task, checkbox, code, quote, ordered-list, hr, table, ai-chat, person (query slash command removed #1919) + expect(commands).toHaveLength(11); // text, header1-3, task, checkbox, code, quote, ordered-list, hr, table (query, ai-chat, person, collection, date have no slash command) // Verify text node commands from BasicNodeTypeRegistry work const textCommands = commands.filter((cmd) => @@ -361,7 +359,7 @@ describe('Core Plugins Integration', () => { registerExternalPlugin(registry, externalPlugin); expect(registry.getAllPlugins()).toHaveLength(initialCount + 1); - expect(registry.getAllSlashCommands()).toHaveLength(14); // 13 core + 1 external (query slash command removed #1919) + expect(registry.getAllSlashCommands()).toHaveLength(12); // 11 core + 1 external }); }); 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 26d2ec457..44cfedae5 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 @@ -12,8 +12,7 @@ import { createPluginFromSchema, registerSchemaPlugin, unregisterSchemaPlugin, - initializeSchemaPluginSystem, - PLUGIN_PRIORITIES + initializeSchemaPluginSystem } from '$lib/plugins/schema-plugin-loader'; import type { SchemaNode } from '$lib/types/schema-node'; import { pluginRegistry } from '$lib/plugins/plugin-registry'; @@ -71,16 +70,9 @@ describe('Schema Plugin Loader - createPluginFromSchema()', () => { description: 'Schema for invoices', version: '1.0.0', config: { - slashCommands: [ - { - id: 'invoice', - name: 'Sales Invoice', - description: 'Schema for invoices', - contentTemplate: '', - nodeType: 'invoice', - priority: PLUGIN_PRIORITIES.CUSTOM_ENTITY - } - ], + // Entity types are not slash-creatable — no slash command is generated for + // user-defined schema types. Instances are created via the sidenav's type view. + slashCommands: [], canHaveChildren: true, canBeChild: true } @@ -95,7 +87,8 @@ describe('Schema Plugin Loader - createPluginFromSchema()', () => { const plugin = createPluginFromSchema(schemaNode); expect(plugin.name).toBe('Customer Invoice'); - expect(plugin.config.slashCommands[0].name).toBe('Customer Invoice'); + // No slash command is generated for the display name to propagate onto. + expect(plugin.config.slashCommands).toHaveLength(0); }); it('should use schema content as display name, falling back to humanized ID when content is empty', () => { @@ -108,15 +101,15 @@ describe('Schema Plugin Loader - createPluginFromSchema()', () => { expect(createPluginFromSchema(emptyContent).name).toBe('Sales Invoice'); }); - it('should set correct priority for custom entities', () => { + it('should not generate a slash command for custom entities', () => { const schemaNode = createMockSchemaNode('invoice', { description: 'Invoice' }); const plugin = createPluginFromSchema(schemaNode); - expect(plugin.config.slashCommands[0].priority).toBe(PLUGIN_PRIORITIES.CUSTOM_ENTITY); - expect(PLUGIN_PRIORITIES.CUSTOM_ENTITY).toBe(50); + // Entity types (core and user-defined alike) are not slash-creatable. + expect(plugin.config.slashCommands).toEqual([]); }); it('should use schema version as plugin version', () => { @@ -140,29 +133,29 @@ describe('Schema Plugin Loader - createPluginFromSchema()', () => { expect(plugin.node).toBeUndefined(); }); - it('should set contentTemplate to empty string when no titleTemplate (node name is editable)', () => { + it('should not generate a slash command when no titleTemplate is set', () => { const schemaNode = createMockSchemaNode('customer', { description: 'Customer' }); const plugin = createPluginFromSchema(schemaNode); - expect(plugin.config.slashCommands[0].contentTemplate).toBe(''); + expect(plugin.config.slashCommands).toHaveLength(0); }); - it('should set contentTemplate to empty string when titleTemplate is set (viewer shows template as hint)', () => { + it('should not generate a slash command when titleTemplate is set', () => { const schemaNode: SchemaNode = { ...createMockSchemaNode('customer', { description: 'Customer' }), titleTemplate: '{first_name} {last_name}' }; const plugin = createPluginFromSchema(schemaNode); - expect(plugin.config.slashCommands[0].contentTemplate).toBe(''); + expect(plugin.config.slashCommands).toHaveLength(0); }); - it('should set nodeType to schema ID for slash command creation', () => { + it('should not generate a slash command regardless of schema ID', () => { const schemaNode = createMockSchemaNode('customEntity', { description: 'Custom Entity' }); const plugin = createPluginFromSchema(schemaNode); - expect(plugin.config.slashCommands[0].nodeType).toBe('customEntity'); + expect(plugin.config.slashCommands).toHaveLength(0); }); }); diff --git a/packages/desktop-app/src/tests/services/slash-command-service-registry-integration.test.ts b/packages/desktop-app/src/tests/services/slash-command-service-registry-integration.test.ts index 74e30e6c9..145fa8d0f 100644 --- a/packages/desktop-app/src/tests/services/slash-command-service-registry-integration.test.ts +++ b/packages/desktop-app/src/tests/services/slash-command-service-registry-integration.test.ts @@ -24,7 +24,7 @@ describe('SlashCommandService Registry Integration', () => { it('should return registry commands when registry integration enabled', () => { const commands = service.getCommands(); - expect(commands).toHaveLength(13); // 13 core plugin commands (text, header1-3, task, checkbox, code, quote, ordered-list, hr, table, ai-chat, person) — query slash command removed #1919 + expect(commands).toHaveLength(11); // 11 core plugin commands (text, header1-3, task, checkbox, code, quote, ordered-list, hr, table) — query, ai-chat, person have no slash command // Commands are sorted by priority (higher first), then by name const commandIds = commands.map((cmd) => cmd.id); @@ -162,7 +162,7 @@ describe('SlashCommandService Registry Integration', () => { const found = service.findCommand('task'); // Registry-based behavior includes all core commands - expect(commands).toHaveLength(13); // All 13 core slash commands (text, header1-3, task, checkbox, code, quote, ordered-list, hr, table, ai-chat, person) — query slash command removed #1919 + expect(commands).toHaveLength(11); // All 11 core slash commands (text, header1-3, task, checkbox, code, quote, ordered-list, hr, table) — query, ai-chat, person have no slash command expect(filtered.length).toBe(1); expect(found?.id).toBe('task'); }); From b946ed71bc92561d0590c316e0217956dd49ce96 Mon Sep 17 00:00:00 2001 From: mstomar125 <185037323+mstomar125@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:53:34 +0200 Subject: [PATCH 2/3] Fix title_template regression from merging with #2179's resolveDisplayTitle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging main pulled in #2179 (Fix stale cached title on tab titles, query views, and collections), which added PluginRegistry.resolveDisplayTitle() and made getNodeTitle() delegate to it. Both derive hasTitleTemplate via findSlashCommand(nodeType)?.hasTitleTemplate — the exact field this PR's earlier commit stopped populating when it emptied createPluginFromSchema's slashCommands array. Combined, every title_template-driven user-defined schema type would have shown a blank/stale title instead of its computed name across node-row.svelte, resolveDisplayTitle's consumers (query list/ kanban/table views, the collections sidebar), and getNodeTitle's consumers (tab titles). Moves hasTitleTemplate/titleTemplate off SlashCommandDefinition and onto PluginDefinition as top-level fields (alongside deletableViaBackspace), since the signal is a per-type property, not tied to slash-command availability. Adds PluginRegistry.hasTitleTemplate()/getTitleTemplate() accessors mirroring the acceptsContentMerge()/deletableViaBackspace() pattern, and updates every consumer (resolveDisplayTitle, node-row.svelte, base-node-viewer.svelte's now-dead custom-entity-slash-command branch) to read from them instead of findSlashCommand(...)?.hasTitleTemplate. createPluginFromSchema now sets hasTitleTemplate/titleTemplate at the plugin level. Updates the 5 test files that constructed synthetic plugins with these fields nested in a slash command, adds direct coverage for the new accessors, and restores the two schema-plugin-loader.test.ts titleTemplate assertions that had collapsed into duplicate bodies (both previously only checked slashCommands is empty, losing coverage of hasTitleTemplate/ titleTemplate propagation). Also fixes stale doc comments (core_schemas.rs, capture_service.rs, agent_session_service.rs, agent_session_service.proto) that described ai-chat nodes as created via the now-removed `/ai-chat` slash command, and drops two GitHub issue-number references from comments this PR was already touching. Co-Authored-By: Claude --- packages/core/src/models/core_schemas.rs | 4 +- .../src/services/agent_session_service.rs | 6 +- .../daemon/src/services/capture_service.rs | 10 +- .../design/components/base-node-viewer.svelte | 9 +- .../src/lib/design/components/node-row.svelte | 5 +- .../src/lib/plugins/plugin-registry.ts | 36 +++++- .../src/lib/plugins/schema-plugin-loader.ts | 7 +- packages/desktop-app/src/lib/plugins/types.ts | 19 +++- .../src/lib/utils/node-display-title.ts | 4 +- .../components/kanban-view-title.test.ts | 16 +-- .../src/tests/components/list-view.test.ts | 16 +-- .../src/tests/components/table-row.test.ts | 16 +-- .../src/tests/plugins/core-plugins.test.ts | 4 +- .../src/tests/plugins/plugin-registry.test.ts | 106 +++++++++++------- .../plugins/schema-plugin-loader.test.ts | 12 +- .../src/tests/stores/collections.test.ts | 32 +----- .../proto/proto/agent_session_service.proto | 2 +- 17 files changed, 167 insertions(+), 137 deletions(-) diff --git a/packages/core/src/models/core_schemas.rs b/packages/core/src/models/core_schemas.rs index 0fe93e179..fbd83dd35 100644 --- a/packages/core/src/models/core_schemas.rs +++ b/packages/core/src/models/core_schemas.rs @@ -250,8 +250,8 @@ pub fn get_core_schemas() -> Vec { // by decision, not by oversight. // // This crate cannot enforce that: slash commands and the surfaces that keep - // projects reachable are frontend concerns. The rule and its known exceptions live - // next to the plugin registry, in the desktop app's `core-plugins.ts`. + // projects reachable are frontend concerns. The rule lives next to the plugin + // registry, in the desktop app's `core-plugins.ts`. SchemaNode { id: "project".to_string(), content: "Project".to_string(), diff --git a/packages/daemon/src/services/agent_session_service.rs b/packages/daemon/src/services/agent_session_service.rs index 8a3ab2542..b3a45a621 100644 --- a/packages/daemon/src/services/agent_session_service.rs +++ b/packages/daemon/src/services/agent_session_service.rs @@ -177,9 +177,9 @@ impl AgentSessionService for AgentSessionHandler { // after the launch response is returned — it does not block session // start. // - // The node already exists (created up front via `/ai-chat`, provider - // mode 2d per ADR-034); `node_id` identifies it so capture backfills - // that node rather than minting a new one. + // The node already exists (created up front, provider mode 2d per + // ADR-034); `node_id` identifies it so capture backfills that node + // rather than minting a new one. // // Capture config is read once here (at launch time) so finalize_capture // doesn't re-hit the filesystem on every session end. Sessions started diff --git a/packages/daemon/src/services/capture_service.rs b/packages/daemon/src/services/capture_service.rs index 86ff1d35a..0667c9cfc 100644 --- a/packages/daemon/src/services/capture_service.rs +++ b/packages/daemon/src/services/capture_service.rs @@ -1,10 +1,10 @@ //! Opt-in session capture: backfills the `ai-chat` node for a PTY session. //! //! Under the unified AIChat model (ADR-034), a PTY session is provider mode 2d -//! of an `ai-chat` node that already exists — it was created up front via the -//! `/ai-chat` slash command and its id is passed through `LaunchSession`. At -//! session end, capture **backfills** that node with the session's -//! transcript/summary/metadata; it does **not** mint a new node. +//! of an `ai-chat` node that already exists — it was created up front (via the +//! desktop app's "AI Chats" sidebar section) and its id is passed through +//! `LaunchSession`. At session end, capture **backfills** that node with the +//! session's transcript/summary/metadata; it does **not** mint a new node. //! //! [`finalize_capture`] is called by the agent session handler after the PTY //! process exits. It reads capture settings from the daemon config and, when @@ -34,7 +34,7 @@ use crate::services::settings_service::{CaptureConfig, CaptureContentSetting}; pub struct CompletedSession { pub id: Uuid, /// ID of the `ai-chat` node this session is a view onto. The node is - /// created up front via `/ai-chat`; capture backfills it. `None` only in + /// created up front (before launch); capture backfills it. `None` only in /// the defensive/legacy case where no node was associated at launch — in /// which case capture is skipped (the unified model always sets this). pub node_id: Option, diff --git a/packages/desktop-app/src/lib/design/components/base-node-viewer.svelte b/packages/desktop-app/src/lib/design/components/base-node-viewer.svelte index 86a18e57c..b812122cc 100644 --- a/packages/desktop-app/src/lib/design/components/base-node-viewer.svelte +++ b/packages/desktop-app/src/lib/design/components/base-node-viewer.svelte @@ -1220,7 +1220,10 @@ // below. Types with an inline editor (person, task) are typed into in place — sending // focus to another pane mid-keystroke would interrupt the user. if (rendersAsEntityRow(newNodeType)) { - handleCustomEntitySlashCommand(promotedNode.id, !!cmdDef?.hasTitleTemplate).catch((err) => + handleCustomEntitySlashCommand( + promotedNode.id, + pluginRegistry.hasTitleTemplate(newNodeType) + ).catch((err) => log.error('Custom entity slash command failed (placeholder path):', err) ); } @@ -1244,8 +1247,8 @@ focusManager.focusNodeFromTypeConversion(node.id, cursorPosition, paneId); sharedNodeStore.updateNode(node.id, updatePayload, { type: 'viewer', viewerId }); if (rendersAsEntityRow(newNodeType)) { - handleCustomEntitySlashCommand(node.id, !!cmdDef?.hasTitleTemplate).catch((err) => - log.error('Custom entity slash command failed (real-node path):', err) + handleCustomEntitySlashCommand(node.id, pluginRegistry.hasTitleTemplate(newNodeType)).catch( + (err) => log.error('Custom entity slash command failed (real-node path):', err) ); } }); diff --git a/packages/desktop-app/src/lib/design/components/node-row.svelte b/packages/desktop-app/src/lib/design/components/node-row.svelte index f6c0dabe0..f0193005e 100644 --- a/packages/desktop-app/src/lib/design/components/node-row.svelte +++ b/packages/desktop-app/src/lib/design/components/node-row.svelte @@ -125,12 +125,11 @@ - {@const nodeSlashCmd = pluginRegistry.findSlashCommand(node.nodeType)} - {@const nodeHasTitleTemplate = !!nodeSlashCmd?.hasTitleTemplate} + {@const nodeHasTitleTemplate = pluginRegistry.hasTitleTemplate(node.nodeType)} {@const nodeTitleDisplay = nodeHasTitleTemplate ? node.title && /\w/.test(node.title) ? node.title - : (nodeSlashCmd?.titleTemplate ?? '') + : (pluginRegistry.getTitleTemplate(node.nodeType) ?? '') : undefined} {#key `${node.id}-${node.nodeType}`} string): string { - const hasTitleTemplate = !!this.findSlashCommand(node.nodeType)?.hasTitleTemplate; + const hasTitleTemplate = this.hasTitleTemplate(node.nodeType); const value = resolveTitleOrContent(node, hasTitleTemplate); return hasTitleTemplate || !formatContent ? value : formatContent(value); } + /** + * True when nodeType's schema is title_template-driven — i.e. `title` is a computed + * property value, genuinely distinct from `content`, rather than a copy of it. See + * `PluginDefinition.hasTitleTemplate`'s doc comment for the full rule. + * + * Returns false for unknown or disabled plugins. + */ + hasTitleTemplate(nodeType: string): boolean { + const plugin = this.plugins.get(nodeType); + if (!plugin || !this.enabledPlugins.has(nodeType)) { + return false; + } + return !!plugin.hasTitleTemplate; + } + + /** + * The raw titleTemplate string (e.g. '{first_name} {last_name}') for nodeType, for + * placeholder display while the template hasn't resolved to any value yet. Undefined when + * `hasTitleTemplate(nodeType)` is false. + */ + getTitleTemplate(nodeType: string): string | undefined { + const plugin = this.plugins.get(nodeType); + if (!plugin || !this.enabledPlugins.has(nodeType)) { + return undefined; + } + return plugin.titleTemplate; + } + /** * Compute a node's tab title. Delegates to the plugin's `getTitle` when one is registered * (e.g. date nodes format "Today"/"Tomorrow" from their id, not from content) — that 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 626be9751..76246da1f 100644 --- a/packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts +++ b/packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts @@ -119,7 +119,12 @@ export function createPluginFromSchema(schema: SchemaNode): PluginDefinition { slashCommands: [], canHaveChildren: true, canBeChild: true - } + }, + // Carried at the plugin level (not on a slash command, since there isn't one) so + // row-rendering surfaces can still tell this type's `title` apart from `content` via + // PluginRegistry.hasTitleTemplate() — see node-row.svelte and resolveDisplayTitle(). + hasTitleTemplate: !!schema.titleTemplate, + titleTemplate: schema.titleTemplate // No node component — custom entities render via BaseNode fallback. // No custom viewer — falls back to BaseNodeViewer. // No custom reference — falls back to BaseNodeReference. diff --git a/packages/desktop-app/src/lib/plugins/types.ts b/packages/desktop-app/src/lib/plugins/types.ts index a38c63e90..fd788db79 100644 --- a/packages/desktop-app/src/lib/plugins/types.ts +++ b/packages/desktop-app/src/lib/plugins/types.ts @@ -172,10 +172,6 @@ export interface SlashCommandDefinition { * Example: For code-block with '```plaintext\n\n```', set to 13 to place cursor after first fence line */ desiredCursorPosition?: number; - /** True if the schema has a titleTemplate — viewer shows template as faint hint instead of editable content */ - hasTitleTemplate?: boolean; - /** The raw titleTemplate string (e.g. '{first_name} {last_name}') for placeholder display */ - titleTemplate?: string; } // Node type configuration @@ -307,6 +303,21 @@ export interface PluginDefinition { * from stored content. Most node types omit this and fall back to node.title || node.content. */ getTitle?: (node: Node) => string | undefined; + + /** + * True when this type's schema is title_template-driven — `title` is a computed property + * value, genuinely distinct from `content`, rather than a copy of it. Row-rendering surfaces + * (query list/kanban/table views, the collections sidebar, the outline's BaseNode fallback) + * use this — via `PluginRegistry.hasTitleTemplate()` — to decide whether to prefer `title` + * over `content`. A per-type property, not tied to slash-command availability: user-defined + * schema types carry it here even though they have no slash command. + */ + hasTitleTemplate?: boolean; + + /** The raw titleTemplate string (e.g. '{first_name} {last_name}') for placeholder display + * while the template hasn't resolved to any value yet. Only meaningful when + * `hasTitleTemplate` is true. */ + titleTemplate?: string; } // Registry statistics for debugging diff --git a/packages/desktop-app/src/lib/utils/node-display-title.ts b/packages/desktop-app/src/lib/utils/node-display-title.ts index 24baf3e2e..d410ea406 100644 --- a/packages/desktop-app/src/lib/utils/node-display-title.ts +++ b/packages/desktop-app/src/lib/utils/node-display-title.ts @@ -16,8 +16,8 @@ * * Callers determine `hasTitleTemplate` themselves, since the answer comes from different * places depending on context (a `SchemaFormLoader` instance inside a node viewer; - * `pluginRegistry.findSlashCommand(nodeType)?.hasTitleTemplate` outside one — see - * `schema-form-loader.svelte.ts`'s `hasTitleTemplate` getter for why the two agree: the + * `pluginRegistry.hasTitleTemplate(nodeType)` outside one — see `schema-form-loader.svelte.ts`'s + * `hasTitleTemplate` getter for why the two agree: the * frontend's notion of "title_template-driven" is custom-schema-scoped, while the backend's * `compute_title()` applies a title_template to any schema carrying one, core or not; they * agree only because no core type ships a template today). diff --git a/packages/desktop-app/src/tests/components/kanban-view-title.test.ts b/packages/desktop-app/src/tests/components/kanban-view-title.test.ts index 952980259..70131d7ee 100644 --- a/packages/desktop-app/src/tests/components/kanban-view-title.test.ts +++ b/packages/desktop-app/src/tests/components/kanban-view-title.test.ts @@ -91,19 +91,9 @@ describe('KanbanView — stale title regression', () => { name: 'Widget', description: 'Custom entity with a title template', version: '1.0.0', - config: { - slashCommands: [ - { - id: 'widget-entity', - name: 'Widget', - description: 'Create Widget', - contentTemplate: '', - nodeType: 'widget-entity', - hasTitleTemplate: true, - titleTemplate: '{first_name} {last_name}' - } - ] - } + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{first_name} {last_name}' }); const node = baseNode({ nodeType: 'widget-entity', content: 'raw', title: 'Jane Doe' }); diff --git a/packages/desktop-app/src/tests/components/list-view.test.ts b/packages/desktop-app/src/tests/components/list-view.test.ts index a6b761eaf..d0918e1e8 100644 --- a/packages/desktop-app/src/tests/components/list-view.test.ts +++ b/packages/desktop-app/src/tests/components/list-view.test.ts @@ -65,19 +65,9 @@ describe('ListView — stale title regression', () => { name: 'Widget', description: 'Custom entity with a title template', version: '1.0.0', - config: { - slashCommands: [ - { - id: 'widget-entity', - name: 'Widget', - description: 'Create Widget', - contentTemplate: '', - nodeType: 'widget-entity', - hasTitleTemplate: true, - titleTemplate: '{first_name} {last_name}' - } - ] - } + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{first_name} {last_name}' }); const node = baseNode({ nodeType: 'widget-entity', content: 'raw', title: 'Jane Doe' }); diff --git a/packages/desktop-app/src/tests/components/table-row.test.ts b/packages/desktop-app/src/tests/components/table-row.test.ts index 11248adec..17dc657fe 100644 --- a/packages/desktop-app/src/tests/components/table-row.test.ts +++ b/packages/desktop-app/src/tests/components/table-row.test.ts @@ -67,19 +67,9 @@ describe('TableRow — stale title regression', () => { name: 'Widget', description: 'Custom entity with a title template', version: '1.0.0', - config: { - slashCommands: [ - { - id: 'widget-entity', - name: 'Widget', - description: 'Create Widget', - contentTemplate: '', - nodeType: 'widget-entity', - hasTitleTemplate: true, - titleTemplate: '{first_name} {last_name}' - } - ] - } + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{first_name} {last_name}' }); const node = baseNode({ nodeType: 'widget-entity', content: 'raw', title: 'Jane Doe' }); diff --git a/packages/desktop-app/src/tests/plugins/core-plugins.test.ts b/packages/desktop-app/src/tests/plugins/core-plugins.test.ts index 8f56da8fd..b9d9dd923 100644 --- a/packages/desktop-app/src/tests/plugins/core-plugins.test.ts +++ b/packages/desktop-app/src/tests/plugins/core-plugins.test.ts @@ -164,7 +164,7 @@ describe('Core Plugins Integration', () => { // Note: Logger output is intentionally silenced during tests const stats = registry.getStats(); expect(stats.pluginsCount).toBe(14); // text, header, task, checkbox, date, code-block, quote-block, ordered-list, horizontal-line, table, query, collection, ai-chat, person - expect(stats.slashCommandsCount).toBe(11); // text: 1, header: 3, task: 1, checkbox: 1, code-block: 1, quote-block: 1, ordered-list: 1, horizontal-line: 1, table: 1, query: 0 (removed #1919), collection: 0, date: 0, ai-chat: 0, person: 0 (entity types are not slash-creatable) + expect(stats.slashCommandsCount).toBe(11); // text: 1, header: 3, task: 1, checkbox: 1, code-block: 1, quote-block: 1, ordered-list: 1, horizontal-line: 1, table: 1, query: 0 (no slash command), collection: 0, date: 0, ai-chat: 0, person: 0 (entity types are not slash-creatable) expect(stats.viewersCount).toBe(5); // date, task, collection, query, and ai-chat have custom viewers expect(stats.referencesCount).toBe(14); // all plugins have references }); @@ -174,7 +174,7 @@ describe('Core Plugins Integration', () => { const stats = registry.getStats(); - // text: 1, header: 3, task: 1, checkbox: 1, code-block: 1, quote-block: 1, ordered-list: 1, horizontal-line: 1, table: 1, query: 0 (removed #1919), date: 0, collection: 0, ai-chat: 0, person: 0 (entity types are not slash-creatable) = 11 total + // text: 1, header: 3, task: 1, checkbox: 1, code-block: 1, quote-block: 1, ordered-list: 1, horizontal-line: 1, table: 1, query: 0 (no slash command), date: 0, collection: 0, ai-chat: 0, person: 0 (entity types are not slash-creatable) = 11 total expect(stats.slashCommandsCount).toBe(11); }); diff --git a/packages/desktop-app/src/tests/plugins/plugin-registry.test.ts b/packages/desktop-app/src/tests/plugins/plugin-registry.test.ts index 5f724457c..559096917 100644 --- a/packages/desktop-app/src/tests/plugins/plugin-registry.test.ts +++ b/packages/desktop-app/src/tests/plugins/plugin-registry.test.ts @@ -1576,27 +1576,17 @@ describe('PluginRegistry - Core Functionality', () => { }); it('prefers node.title for a title_template-driven custom entity type', () => { - // A custom entity schema with a title_template registers hasTitleTemplate on its - // slash command (schema-plugin-loader.ts) — that's the signal getNodeTitle uses to + // A custom entity schema with a title_template sets hasTitleTemplate/titleTemplate on + // the plugin itself (schema-plugin-loader.ts) — that's the signal getNodeTitle uses to // know title is computed and legitimately distinct from content. const plugin: PluginDefinition = { id: 'person', name: 'Person', description: 'Person plugin', version: '1.0.0', - config: { - slashCommands: [ - { - id: 'person', - name: 'Person', - description: 'Create Person', - contentTemplate: '', - nodeType: 'person', - hasTitleTemplate: true, - titleTemplate: '{first_name} {last_name}' - } - ] - } + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{first_name} {last_name}' }; registry.register(plugin); @@ -1684,19 +1674,9 @@ describe('PluginRegistry - Core Functionality', () => { name: 'Person', description: 'Person plugin', version: '1.0.0', - config: { - slashCommands: [ - { - id: 'person', - name: 'Person', - description: 'Create Person', - contentTemplate: '', - nodeType: 'person', - hasTitleTemplate: true, - titleTemplate: '{first_name} {last_name}' - } - ] - } + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{first_name} {last_name}' }; registry.register(plugin); @@ -1748,19 +1728,9 @@ describe('PluginRegistry - Core Functionality', () => { name: 'Person', description: 'Person plugin', version: '1.0.0', - config: { - slashCommands: [ - { - id: 'person', - name: 'Person', - description: 'Create Person', - contentTemplate: '', - nodeType: 'person', - hasTitleTemplate: true, - titleTemplate: '{first_name}_{last_name}' - } - ] - } + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{first_name}_{last_name}' }; registry.register(plugin); @@ -1772,6 +1742,60 @@ describe('PluginRegistry - Core Functionality', () => { }); }); + describe('hasTitleTemplate / getTitleTemplate (core#2152)', () => { + it('reads a per-type plugin field, not a slash command — true for a type with no slash command at all', () => { + const plugin: PluginDefinition = { + id: 'venue', + name: 'Venue', + description: 'Custom entity, no slash command (entity types are not slash-creatable)', + version: '1.0.0', + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{name}' + }; + registry.register(plugin); + + expect(registry.hasTitleTemplate('venue')).toBe(true); + expect(registry.getTitleTemplate('venue')).toBe('{name}'); + }); + + it('returns false/undefined when hasTitleTemplate is unset', () => { + const plugin: PluginDefinition = { + id: 'task', + name: 'Task', + description: 'Task plugin', + version: '1.0.0', + config: { slashCommands: [] } + }; + registry.register(plugin); + + expect(registry.hasTitleTemplate('task')).toBe(false); + expect(registry.getTitleTemplate('task')).toBeUndefined(); + }); + + it('returns false/undefined for an unregistered nodeType', () => { + expect(registry.hasTitleTemplate('nonexistent')).toBe(false); + expect(registry.getTitleTemplate('nonexistent')).toBeUndefined(); + }); + + it('returns false/undefined for a disabled plugin', () => { + const plugin: PluginDefinition = { + id: 'venue', + name: 'Venue', + description: 'Custom entity', + version: '1.0.0', + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{name}' + }; + registry.register(plugin); + registry.setEnabled('venue', false); + + expect(registry.hasTitleTemplate('venue')).toBe(false); + expect(registry.getTitleTemplate('venue')).toBeUndefined(); + }); + }); + describe('Content Merge Acceptance', () => { it('should return false for plugins with acceptsContentMerge: false', () => { const plugin: PluginDefinition = { 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 44cfedae5..c6628aef4 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 @@ -133,19 +133,27 @@ describe('Schema Plugin Loader - createPluginFromSchema()', () => { expect(plugin.node).toBeUndefined(); }); - it('should not generate a slash command when no titleTemplate is set', () => { + it('should not generate a slash command, and reports hasTitleTemplate: false, when no titleTemplate is set', () => { const schemaNode = createMockSchemaNode('customer', { description: 'Customer' }); const plugin = createPluginFromSchema(schemaNode); expect(plugin.config.slashCommands).toHaveLength(0); + expect(plugin.hasTitleTemplate).toBe(false); + expect(plugin.titleTemplate).toBeUndefined(); }); - it('should not generate a slash command when titleTemplate is set', () => { + it('should not generate a slash command, but still carries hasTitleTemplate/titleTemplate at the plugin level, when titleTemplate is set', () => { + // No slash command exists to carry this signal anymore (entity types aren't + // slash-creatable) — row-rendering surfaces (node-row.svelte, resolveDisplayTitle) read + // it via PluginRegistry.hasTitleTemplate()/getTitleTemplate() instead, off the plugin + // definition itself. const schemaNode: SchemaNode = { ...createMockSchemaNode('customer', { description: 'Customer' }), titleTemplate: '{first_name} {last_name}' }; const plugin = createPluginFromSchema(schemaNode); expect(plugin.config.slashCommands).toHaveLength(0); + expect(plugin.hasTitleTemplate).toBe(true); + expect(plugin.titleTemplate).toBe('{first_name} {last_name}'); }); it('should not generate a slash command regardless of schema ID', () => { diff --git a/packages/desktop-app/src/tests/stores/collections.test.ts b/packages/desktop-app/src/tests/stores/collections.test.ts index 120900b84..3710aad36 100644 --- a/packages/desktop-app/src/tests/stores/collections.test.ts +++ b/packages/desktop-app/src/tests/stores/collections.test.ts @@ -492,19 +492,9 @@ describe('Collections Store', () => { name: 'Widget', description: 'Custom entity with a title template', version: '1.0.0', - config: { - slashCommands: [ - { - id: 'widget-entity', - name: 'Widget', - description: 'Create Widget', - contentTemplate: '', - nodeType: 'widget-entity', - hasTitleTemplate: true, - titleTemplate: '{first_name} {last_name}' - } - ] - } + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{first_name} {last_name}' }); const members = new Map([ @@ -543,19 +533,9 @@ describe('Collections Store', () => { name: 'Widget', description: 'Custom entity with a title template', version: '1.0.0', - config: { - slashCommands: [ - { - id: 'widget-entity', - name: 'Widget', - description: 'Create Widget', - contentTemplate: '', - nodeType: 'widget-entity', - hasTitleTemplate: true, - titleTemplate: '{first_name}_{last_name}' - } - ] - } + config: { slashCommands: [] }, + hasTitleTemplate: true, + titleTemplate: '{first_name}_{last_name}' }); const members = new Map([ diff --git a/packages/proto/proto/agent_session_service.proto b/packages/proto/proto/agent_session_service.proto index 66f356464..51d0761e8 100644 --- a/packages/proto/proto/agent_session_service.proto +++ b/packages/proto/proto/agent_session_service.proto @@ -41,7 +41,7 @@ message LaunchSessionRequest { // ID of the `ai-chat` node this PTY session is a view onto (provider mode // 2d, per ADR-034). When set, capture backfills *this* node at session end // rather than minting a new one. The desktop app always sets it (the node is - // created up front via `/ai-chat`); other callers (e.g. the CLI) launch a + // created up front, before launch); other callers (e.g. the CLI) launch a // session not bound to a node and omit it, in which case capture is skipped. optional string node_id = 5; } From 26bc855a81ea74d8800f18dceb4b5267861bc250 Mon Sep 17 00:00:00 2001 From: mstomar125 <185037323+mstomar125@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:30:12 +0200 Subject: [PATCH 3/3] Address round-2 review: remove dead entity-slash-command branch, fix Unicode/empty-template edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - base-node-viewer.svelte: delete handleCustomEntitySlashCommand and its two rendersAsEntityRow(newNodeType) call sites — unreachable now that no first-party plugin's slash command ever targets an entity-row type (ai-chat, person, and all user-defined schema types have slashCommands: [] after this PR). No production registration path can still reach it, so per this repo's no-dead-code convention it's deleted rather than kept as latent "extension point" infrastructure. - node-row.svelte: use the same Unicode-aware HAS_RESOLVED_CHARACTER_RE (exported from node-display-title.ts) that resolveDisplayTitle's consumers use, instead of the ASCII-only /\w/ — a title_template resolving to a non-Latin-script name (CJK, Arabic, Cyrillic, ...) now renders correctly here too instead of falling back to the raw template placeholder. - plugin-registry.ts: getTitleTemplate() now gates on hasTitleTemplate() instead of titleTemplate's own truthiness, so a plugin with titleTemplate: '' (which hasTitleTemplate correctly treats as "no template") can't return a defined-but-empty string, matching the method's documented "undefined means no template" contract. Co-Authored-By: Claude --- .../design/components/base-node-viewer.svelte | 49 ------------------- .../src/lib/design/components/node-row.svelte | 5 +- .../src/lib/plugins/plugin-registry.ts | 8 +-- .../src/lib/utils/node-display-title.ts | 2 +- .../src/tests/plugins/plugin-registry.test.ts | 22 ++++++++- 5 files changed, 30 insertions(+), 56 deletions(-) diff --git a/packages/desktop-app/src/lib/design/components/base-node-viewer.svelte b/packages/desktop-app/src/lib/design/components/base-node-viewer.svelte index b812122cc..65721b115 100644 --- a/packages/desktop-app/src/lib/design/components/base-node-viewer.svelte +++ b/packages/desktop-app/src/lib/design/components/base-node-viewer.svelte @@ -1103,38 +1103,6 @@ } } - /** - * Handle custom entity slash command side-effects: navigate to other pane and - * optionally create a blank text sibling below when the entity is the last visible node. - * - * @param entityNodeId - ID of the custom entity node - * @param hasTitleTemplate - Whether the schema has a title_template (node is read-only inline) - */ - async function handleCustomEntitySlashCommand( - entityNodeId: string, - hasTitleTemplate: boolean - ): Promise { - const { getNavigationService } = await import('$lib/services/navigation-service'); - getNavigationService().navigateToNodeInOtherPane(entityNodeId, paneId); - // Only create blank text node if the entity is the last visible node - // (no existing node below it to continue typing into). - // When hasTitleTemplate is false the node is editable inline (like a task), - // so no sibling is needed. - if (hasTitleTemplate) { - const rendered = nodesToRender(); - const nodeIndex = rendered.findIndex((n) => n.id === entityNodeId); - const isLast = nodeIndex === rendered.length - 1; - if (isLast) { - handleCreateNewNode({ - afterNodeId: entityNodeId, - nodeType: 'text', - currentContent: '', - newContent: '' - }); - } - } - } - /** * Handle slash command selection. * Placeholder promotion uses the tick-deferred, isPromoting-guarded path; existing @@ -1215,18 +1183,6 @@ // Clear promotion flag after state updates complete isPromoting = false; - - // Entity nodes (no inline editor): open in other pane + optionally create text node - // below. Types with an inline editor (person, task) are typed into in place — sending - // focus to another pane mid-keystroke would interrupt the user. - if (rendersAsEntityRow(newNodeType)) { - handleCustomEntitySlashCommand( - promotedNode.id, - pluginRegistry.hasTitleTemplate(newNodeType) - ).catch((err) => - log.error('Custom entity slash command failed (placeholder path):', err) - ); - } }); } else { log.debug('Updating node type for real node'); @@ -1246,11 +1202,6 @@ if (!sharedNodeStore.hasNode(node.id)) return; focusManager.focusNodeFromTypeConversion(node.id, cursorPosition, paneId); sharedNodeStore.updateNode(node.id, updatePayload, { type: 'viewer', viewerId }); - if (rendersAsEntityRow(newNodeType)) { - handleCustomEntitySlashCommand(node.id, pluginRegistry.hasTitleTemplate(newNodeType)).catch( - (err) => log.error('Custom entity slash command failed (real-node path):', err) - ); - } }); } } diff --git a/packages/desktop-app/src/lib/design/components/node-row.svelte b/packages/desktop-app/src/lib/design/components/node-row.svelte index f0193005e..40edafafe 100644 --- a/packages/desktop-app/src/lib/design/components/node-row.svelte +++ b/packages/desktop-app/src/lib/design/components/node-row.svelte @@ -16,6 +16,7 @@ import { pluginRegistry } from '$lib/plugins/plugin-registry'; import { extractNodeMetadata } from '$lib/design/components/schema-field-update'; import { rendersAsEntityRow } from '$lib/design/components/node-type-predicates'; + import { HAS_RESOLVED_CHARACTER_RE } from '$lib/utils/node-display-title'; import { extractFallbackDisplayContent, extractFallbackMetadata @@ -127,7 +128,7 @@ it); it fires only via the plugin-component branch above. Kept for branch symmetry. --> {@const nodeHasTitleTemplate = pluginRegistry.hasTitleTemplate(node.nodeType)} {@const nodeTitleDisplay = nodeHasTitleTemplate - ? node.title && /\w/.test(node.title) + ? node.title && HAS_RESOLVED_CHARACTER_RE.test(node.title) ? node.title : (pluginRegistry.getTitleTemplate(node.nodeType) ?? '') : undefined} @@ -138,7 +139,7 @@ autoFocus={node.autoFocus} content={node.content} readonly={nodeHasTitleTemplate} - displayContentIsPlaceholder={nodeHasTitleTemplate && !(node.title && /\w/.test(node.title))} + displayContentIsPlaceholder={nodeHasTitleTemplate && !(node.title && HAS_RESOLVED_CHARACTER_RE.test(node.title))} displayContent={nodeTitleDisplay !== undefined ? nodeTitleDisplay : extractFallbackDisplayContent(node.content, node.nodeType)} diff --git a/packages/desktop-app/src/lib/plugins/plugin-registry.ts b/packages/desktop-app/src/lib/plugins/plugin-registry.ts index b255ffeab..d75fc6cec 100644 --- a/packages/desktop-app/src/lib/plugins/plugin-registry.ts +++ b/packages/desktop-app/src/lib/plugins/plugin-registry.ts @@ -504,11 +504,13 @@ export class PluginRegistry { * `hasTitleTemplate(nodeType)` is false. */ getTitleTemplate(nodeType: string): string | undefined { - const plugin = this.plugins.get(nodeType); - if (!plugin || !this.enabledPlugins.has(nodeType)) { + // Gate on hasTitleTemplate (not just plugin.titleTemplate's own truthiness) so an + // empty-string titleTemplate — which hasTitleTemplate treats as "no template" — can't + // slip through here as a defined-but-empty value, contradicting this method's contract. + if (!this.hasTitleTemplate(nodeType)) { return undefined; } - return plugin.titleTemplate; + return this.plugins.get(nodeType)?.titleTemplate; } /** diff --git a/packages/desktop-app/src/lib/utils/node-display-title.ts b/packages/desktop-app/src/lib/utils/node-display-title.ts index d410ea406..41b1040c9 100644 --- a/packages/desktop-app/src/lib/utils/node-display-title.ts +++ b/packages/desktop-app/src/lib/utils/node-display-title.ts @@ -28,7 +28,7 @@ * CJK or Arabic name) is correctly recognized as resolved, not misclassified as empty * alongside a genuinely-unresolved separator-only value like `" — "` — see the guard below. */ -const HAS_RESOLVED_CHARACTER_RE = /[\p{L}\p{N}]/u; +export const HAS_RESOLVED_CHARACTER_RE = /[\p{L}\p{N}]/u; export function resolveTitleOrContent( node: { title?: string | null; content?: string | null } | null | undefined, diff --git a/packages/desktop-app/src/tests/plugins/plugin-registry.test.ts b/packages/desktop-app/src/tests/plugins/plugin-registry.test.ts index 559096917..2bbe0d4f8 100644 --- a/packages/desktop-app/src/tests/plugins/plugin-registry.test.ts +++ b/packages/desktop-app/src/tests/plugins/plugin-registry.test.ts @@ -1742,7 +1742,7 @@ describe('PluginRegistry - Core Functionality', () => { }); }); - describe('hasTitleTemplate / getTitleTemplate (core#2152)', () => { + describe('hasTitleTemplate / getTitleTemplate (issue #2152)', () => { it('reads a per-type plugin field, not a slash command — true for a type with no slash command at all', () => { const plugin: PluginDefinition = { id: 'venue', @@ -1794,6 +1794,26 @@ describe('PluginRegistry - Core Functionality', () => { expect(registry.hasTitleTemplate('venue')).toBe(false); expect(registry.getTitleTemplate('venue')).toBeUndefined(); }); + + it('returns undefined, not an empty string, when titleTemplate is set but hasTitleTemplate is false', () => { + // createPluginFromSchema derives hasTitleTemplate as !!schema.titleTemplate, so an + // empty-string titleTemplate produces hasTitleTemplate: false but titleTemplate: ''. + // getTitleTemplate must honor hasTitleTemplate as the gate, not titleTemplate's own + // truthiness, so callers can rely on its documented "undefined means no template" + // contract without also having to check hasTitleTemplate themselves. + const plugin: PluginDefinition = { + id: 'venue', + name: 'Venue', + description: 'Custom entity', + version: '1.0.0', + config: { slashCommands: [] }, + hasTitleTemplate: false, + titleTemplate: '' + }; + registry.register(plugin); + + expect(registry.getTitleTemplate('venue')).toBeUndefined(); + }); }); describe('Content Merge Acceptance', () => {