Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/models/core_schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,8 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
// 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(),
Expand Down
6 changes: 3 additions & 3 deletions packages/daemon/src/services/agent_session_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions packages/daemon/src/services/capture_service.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down Expand Up @@ -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');
Expand All @@ -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)
);
}
});
}
}
Expand Down
10 changes: 5 additions & 5 deletions packages/desktop-app/src/lib/design/components/node-row.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -125,12 +126,11 @@
<!-- Custom schema entities also render here (no lazy-loaded node component) -->
<!-- Note: on:taskStateChanged below is inert on plain BaseNode (only task-node dispatches
it); it fires only via the plugin-component branch above. Kept for branch symmetry. -->
{@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}`}
<BaseNode
Expand All @@ -139,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)}
Expand Down
35 changes: 9 additions & 26 deletions packages/desktop-app/src/lib/plugins/core-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand All @@ -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
},
Expand Down Expand Up @@ -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
Expand Down
38 changes: 35 additions & 3 deletions packages/desktop-app/src/lib/plugins/plugin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,10 @@ export class PluginRegistry {
* text, not a tab-specific title, and a date node's `id` is only guaranteed to parse as a
* date in production, not in every synthetic id these surfaces might encounter.
*
* `hasTitleTemplate` comes from `findSlashCommand`'s flag — the same one `node-row.svelte`
* uses for the identical purpose when rendering a custom-entity node inline.
* `hasTitleTemplate` comes from `PluginRegistry.hasTitleTemplate()` — the same accessor
* `node-row.svelte` uses for the identical purpose when rendering a custom-entity node
* inline. It reads a per-type plugin field, not a slash-command flag, so it applies whether
* or not the type is slash-creatable.
*
* @param formatContent - Optional reformatting (e.g. `stripMarkdown`) applied ONLY when the
* resolved value came from `content` — never on a title_template-computed title, which is a
Expand All @@ -476,11 +478,41 @@ export class PluginRegistry {
* formatting-significant characters (underscores, asterisks, backticks).
*/
resolveDisplayTitle(node: Node, formatContent?: (content: string) => 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
Expand Down
Loading