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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import { untrack } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { sharedNodeStore } from '$lib/services/shared-node-store.svelte';
import { pluginRegistry } from '$lib/plugins/plugin-registry';
import { createLogger } from '$lib/utils/logger';
import type { Node } from '$lib/types';
import type { SchemaNode } from '$lib/types/schema-node';
Expand Down Expand Up @@ -176,7 +177,7 @@
}

function titleOf(node: Node): string {
return node.title || node.content || 'Untitled';
return pluginRegistry.resolveDisplayTitle(node) || 'Untitled';
}

/** The column a node currently belongs to (for the move control's value). */
Expand Down
13 changes: 8 additions & 5 deletions packages/desktop-app/src/lib/components/query/list-view.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<!--
ListView - Simple title list for QueryNodeViewer

One row per result, showing the node title (prefer node.title from title_template,
fall back to content, then "Untitled"). Reads each node from sharedNodeStore so
rows stay reactive to edits made in another pane. Clicking a row opens the node
via onRowClick. Paginated at the same PAGE_SIZE as TableView for consistency.
One row per result, showing the node's current display value via
pluginRegistry.resolveDisplayTitle (title only for title_template-driven schemas, content
otherwise — see node-display-title.ts), falling back to "Untitled". Reads each node
from sharedNodeStore so rows stay reactive to edits made in another pane. Clicking a
row opens the node via onRowClick. Paginated at the same PAGE_SIZE as TableView for
consistency.
-->

<script lang="ts">
import { sharedNodeStore } from '$lib/services/shared-node-store.svelte';
import { pluginRegistry } from '$lib/plugins/plugin-registry';
import { Button } from '$lib/components/ui/button';

let {
Expand All @@ -32,7 +35,7 @@
{#each pageIds as id (id)}
{@const node = sharedNodeStore.getNode(id)}
{#if node}
{@const title = node.title || node.content || 'Untitled'}
{@const title = pluginRegistry.resolveDisplayTitle(node) || 'Untitled'}
<li>
<button class="list-row" onclick={() => onRowClick(id)} title={`Open ${title}`}>
{title}
Expand Down
19 changes: 12 additions & 7 deletions packages/desktop-app/src/lib/components/query/table-row.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<script lang="ts">
import type { SchemaField } from '$lib/types/schema-node';
import { sharedNodeStore } from '$lib/services/shared-node-store.svelte';
import { pluginRegistry } from '$lib/plugins/plugin-registry';
import { TableRow as UiTableRow, TableCell } from '$lib/components/ui/table';

let {
Expand Down Expand Up @@ -41,17 +42,20 @@
for (const col of columns) {
const fieldSchema = fieldSchemaMap.get(col.field);
// Resolution order:
// 1. For 'content' column: prefer node.title (computed by title_template) over raw content
// 1. For 'content' column: the node's current display value (pluginRegistry
// .resolveDisplayTitle — title only for title_template-driven schemas, content
// otherwise; see node-display-title.ts). node.title alone is stale for non-template
// types, since it's only refreshed by a backend round-trip while optimistic edits
// patch content directly.
// 2. camelCase top-level (typed core fields like task.dueDate serialized from Rust)
// 3. snake_case top-level (fallback)
// 4. node.properties[field] (user-defined fields on custom schema nodes)
const camelKey = toCamelCase(col.field);
const props = node.properties as Record<string, unknown> | undefined;
const rawValue =
(col.field === 'content' && node.title ? node.title : undefined) ??
nodeRecord[camelKey] ??
nodeRecord[col.field] ??
props?.[col.field];
col.field === 'content'
? pluginRegistry.resolveDisplayTitle(node)
: (nodeRecord[camelKey] ?? nodeRecord[col.field] ?? props?.[col.field]);

if (rawValue === null || rawValue === undefined) {
map.set(col.field, '');
Expand Down Expand Up @@ -85,10 +89,11 @@
return map;
});

// For title_template nodes, prefer the computed title over raw content
// Current display title (title_template-driven types) or content (everything else) —
// see pluginRegistry.resolveDisplayTitle / node-display-title.ts.
const nodeContent = $derived.by(() => {
const node = sharedNodeStore.getNode(id);
return node?.title ?? node?.content ?? '';
return (node && pluginRegistry.resolveDisplayTitle(node)) ?? '';
});

// Reactive existence check — re-evaluates when the node is deleted from the store
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

import { pluginRegistry } from '$lib/plugins/plugin-registry';
import { resolveTitleOrContent } from '$lib/utils/node-display-title';

/**
* True when a plugin registers an inline node component for this type.
Expand Down Expand Up @@ -50,31 +51,16 @@ export function needsGenericSchemaForm(nodeType: string): boolean {
/**
* The value a viewer header shows while it is NOT focused.
*
* Which field wins depends on whether the node's schema is title_template-driven:
*
* - `hasTitleTemplate` — `title` is computed from the node's properties and is a genuinely
* different value from `content`, so it is the only valid display value. There is no
* fallback to `content`: an unresolved title renders empty and the read-only header shows
* the titleTemplate placeholder instead. A title with no word characters (e.g. the `" "`
* left by a `"{first_name} {last_name}"` template with both fields empty) counts as
* unresolved, matching the `/\w/` guard node-row.svelte applies when rendering these
* nodes inline — otherwise the header would render blank with no placeholder.
* - otherwise — `content` is the source of truth. The store's cached `title` is only
* refreshed by a backend round-trip, while optimistic content updates leave it untouched,
* so preferring `title` here would surface a stale value (e.g. a title captured mid-slash
* command, before the node's type conversion) whenever the header loses focus.
*
* Markdown header syntax is stripped from content only (`## Foo` → `Foo`), for header nodes
* whose content carries the `#` markers. A template-computed title is built from property
* values and has no such syntax to strip.
* Delegates the title-vs-content decision to `resolveTitleOrContent` (see that function for
* the full rule and why it exists) and additionally strips markdown header syntax from a
* content-sourced value (`## Foo` → `Foo`), for header nodes whose content carries the `#`
* markers. A template-computed title is built from property values and has no such syntax
* to strip, so the stripping only applies on the `content` branch.
*/
export function computeHeaderDisplayValue(
node: { title?: string | null; content?: string | null } | null | undefined,
hasTitleTemplate: boolean
): string {
if (hasTitleTemplate) {
const title = node?.title ?? '';
return /\w/.test(title) ? title : '';
}
return (node?.content ?? '').replace(/^#+\s*/, '');
const value = resolveTitleOrContent(node, hasTitleTemplate);
return hasTitleTemplate ? value : value.replace(/^#+\s*/, '');
}
40 changes: 33 additions & 7 deletions packages/desktop-app/src/lib/plugins/plugin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
} from './types';
import type { Node } from '$lib/types';
import { createLogger } from '$lib/utils/logger';
import { resolveTitleOrContent } from '$lib/utils/node-display-title';

const log = createLogger('PluginRegistry');

Expand Down Expand Up @@ -453,20 +454,45 @@ export class PluginRegistry {
}

/**
* Compute a node's tab title via its plugin's getTitle, falling back to
* node.title || node.content when the plugin doesn't define one.
* The node's current display value using the title-vs-content rule alone — see
* `resolveTitleOrContent`'s doc comment for the full rule and the frontend/backend
* agreement it depends on. No `plugin.getTitle` override.
*
* node.title is computed and persisted by the backend (interpolated from the node
* type's schema title_template, when one exists — see NodeService::compute_title in
* the Rust core) — it's already the right display title for any node type without
* type-specific title logic, so no client-side schema lookup is needed here.
* This is the single call site every row-based, one-node-per-line surface (query
* list/kanban/table views, the collections sidebar) should use rather than re-deriving
* title-vs-content themselves. It deliberately does NOT apply a type's `getTitle` override
* (e.g. the date plugin's "Today"/"Tomorrow" formatting) — those surfaces show plain node
* 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.
*
* @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
* property value assembled by the template, not raw markdown/text to reformat. A caller
* that unconditionally wraps this method's return value in its own formatter instead would
* apply that formatting to a computed title too, corrupting property values containing
* formatting-significant characters (underscores, asterisks, backticks).
*/
resolveDisplayTitle(node: Node, formatContent?: (content: string) => string): string {
const hasTitleTemplate = !!this.findSlashCommand(node.nodeType)?.hasTitleTemplate;
const value = resolveTitleOrContent(node, hasTitleTemplate);
return hasTitleTemplate || !formatContent ? value : formatContent(value);
}

/**
* 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
* override is specific to the tab-title contract, unlike `resolveDisplayTitle` above.
* Otherwise falls back to `resolveDisplayTitle`.
*/
getNodeTitle(node: Node): string | undefined {
const plugin = this.plugins.get(node.nodeType);
if (plugin && this.enabledPlugins.has(node.nodeType) && plugin.getTitle) {
return plugin.getTitle(node);
}
return node.title || node.content || undefined;
return this.resolveDisplayTitle(node) || undefined;
}

/**
Expand Down
14 changes: 10 additions & 4 deletions packages/desktop-app/src/lib/stores/collections.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createLogger } from '$lib/utils/logger';
import { onDaemonReconnect } from '$lib/services/daemon-status';
import { stripMarkdown } from '$lib/services/markdown-utils';
import { databaseStore } from '$lib/stores/database.svelte';
import { pluginRegistry } from '$lib/plugins/plugin-registry';

const log = createLogger('CollectionsStore');

Expand Down Expand Up @@ -439,10 +440,15 @@ class CollectionsStore {
.filter((node) => !NON_CONTENT_NODE_TYPES.has(node.nodeType))
.map((node) => ({
id: node.id,
// Prefer the cleaned title; fall back to the node content with its
// markdown stripped so an imported header root shows "ACP Integration
// Architecture", not the raw "# ACP Integration Architecture".
name: node.title || stripMarkdown(node.content),
// pluginRegistry.resolveDisplayTitle already picks title vs. content correctly
// (title only for title_template-driven schemas — a plain node.title fallback is
// stale for everything else, since it's only refreshed by a backend round-trip).
// stripMarkdown is passed in as the content formatter, not wrapped around the
// whole call, so it cleans an imported header root ("# ACP Integration
// Architecture" -> "ACP Integration Architecture") WITHOUT also running over a
// title_template-computed name, which is a property value, not markdown — a name
// containing an underscore or asterisk would otherwise get silently mangled.
name: pluginRegistry.resolveDisplayTitle(node, stripMarkdown),
nodeType: node.nodeType,
}))
);
Expand Down
45 changes: 45 additions & 0 deletions packages/desktop-app/src/lib/utils/node-display-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* The single rule for choosing between a node's `title` and `content` as its current
* display value.
*
* `sharedNodeStore`'s cached `title` is refreshed only by a backend round-trip. Optimistic
* content edits patch `content` and leave `title` untouched, so `title` is stale for any
* node whose schema doesn't compute it — e.g. a title captured mid-slash-command, before a
* node's type conversion, or simply the last-synced value for a node whose content has since
* changed. `node.title || node.content` (the pattern this replaces) surfaces that stale value
* on every surface using it.
*
* The correct rule: prefer `title` only when the node's schema is genuinely title_template-
* driven — there `title` is computed from properties and is a legitimately different value
* from `content`, so it's the only valid display value (an unresolved template renders empty,
* not a fallback to content). Every other type has `content` as its source of truth.
*
* 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
* 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).
*/
/**
* Matches any Unicode letter or number. Used (not the ASCII-only `\w`) so a fully-resolved
* title in a non-Latin script (e.g. a `{first_name} {last_name}` template evaluating to a
* 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 function resolveTitleOrContent(
node: { title?: string | null; content?: string | null } | null | undefined,
hasTitleTemplate: boolean
): string {
if (hasTitleTemplate) {
const title = node?.title ?? '';
// A title with no letters or numbers in any script (e.g. the `" "` left by a
// `"{first} {last}"` template with both fields empty, or `" — "` left by a template
// whose only literal text is a separator) counts as unresolved.
return HAS_RESOLVED_CHARACTER_RE.test(title) ? title : '';
}
return node?.content ?? '';
}
Loading