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..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,15 +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, !!cmdDef?.hasTitleTemplate).catch((err) => - log.error('Custom entity slash command failed (placeholder path):', err) - ); - } }); } else { log.debug('Updating node type for real node'); @@ -1243,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, !!cmdDef?.hasTitleTemplate).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..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 @@ -125,12 +126,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 && HAS_RESOLVED_CHARACTER_RE.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 { + // 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 this.plugins.get(nodeType)?.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 39b47fdb3..76246da1f 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,23 +113,18 @@ 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 - } + }, + // 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. @@ -160,7 +144,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 +183,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 +192,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 +216,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/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..41b1040c9 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). @@ -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/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 73a5adba7..b9d9dd923 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 (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 }); @@ -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 (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); }); 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/plugin-registry.test.ts b/packages/desktop-app/src/tests/plugins/plugin-registry.test.ts index 5f724457c..2bbe0d4f8 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,80 @@ describe('PluginRegistry - Core Functionality', () => { }); }); + 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', + 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(); + }); + + 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', () => { 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 26d2ec457..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 @@ -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,37 @@ 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, and reports hasTitleTemplate: false, 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); + expect(plugin.hasTitleTemplate).toBe(false); + expect(plugin.titleTemplate).toBeUndefined(); }); - it('should set contentTemplate to empty string when titleTemplate is set (viewer shows template as hint)', () => { + 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[0].contentTemplate).toBe(''); + expect(plugin.config.slashCommands).toHaveLength(0); + expect(plugin.hasTitleTemplate).toBe(true); + expect(plugin.titleTemplate).toBe('{first_name} {last_name}'); }); - 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'); }); 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; }