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 @@ -78,7 +78,7 @@ export const PLUGIN_PRIORITIES = {
* humanizeSchemaId('sales-invoice') // 'Sales Invoice'
* ```
*/
function humanizeSchemaId(id: string): string {
export function humanizeSchemaId(id: string): string {
return (
id
// Insert space before uppercase letters (camelCase → camel Case)
Expand Down
33 changes: 26 additions & 7 deletions packages/desktop-app/src/lib/services/schema-authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,42 @@

import { v4 as uuidv4 } from 'uuid';
import { backendAdapter } from '$lib/services/backend-adapter';
import { humanizeSchemaId } from '$lib/plugins/schema-plugin-loader';
import type { Node } from '$lib/types';

/**
* Mint a fresh, empty instance of the given schema type and return the created
* node.
* Core types whose `content` field IS the node's name, not body text — their
* `NodeBehavior::validate()` rejects empty content. Creating one with `content: ''`
* (the default for body-content types) always fails validation, so these seed a
* type-identifying placeholder instead. Kept as an explicit list: there is no
* frontend signal (schema field, `isCore`, field count) that distinguishes these
* from primitive body-content types like `task`/`text`, so inferring the set would
* silently drift as new Core types are added.
*
* The node is created as a root (`parentId: null`) with empty content and no
* properties; the schema-driven form UI fills in the fields once the node is
* opened. `nodeType` is the schema's id — the same key `QueryNodeViewer` queries
* on — so the new node matches that type's result list.
* `tool` is listed for constraint-consistency, but note its validation also
* requires a non-empty `handler` property — this content default alone does not
* make a bare `tool` creatable via "+New"; it just keeps the content rule uniform
* for the day a tool-creation path (with a handler) exists.
*/
const NAME_AS_CONTENT_TYPES = new Set(['project', 'skill', 'collection', 'agent-guidance', 'tool']);

/**
* Mint a fresh instance of the given schema type and return the created node.
*
* The node is created as a root (`parentId: null`) with no properties; the
* schema-driven form UI fills in the fields once the node is opened. `nodeType`
* is the schema's id — the same key `QueryNodeViewer` queries on — so the new
* node matches that type's result list. Body-content types start empty ("start
* typing"); name-as-content Core types (see `NAME_AS_CONTENT_TYPES`) seed
* `"Untitled {Type}"` so they pass their non-empty-content validation.
*/
export async function createSchemaInstance(typeId: string): Promise<Node> {
const newId = uuidv4();
const content = NAME_AS_CONTENT_TYPES.has(typeId) ? `Untitled ${humanizeSchemaId(typeId)}` : '';
await backendAdapter.createNode({
id: newId,
nodeType: typeId,
content: '',
content,
properties: {},
mentions: [],
parentId: null,
Expand Down
31 changes: 31 additions & 0 deletions packages/desktop-app/src/tests/services/schema-authoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,37 @@ describe('createSchemaInstance', () => {
expect(createArg.id.length).toBeGreaterThan(0);
});

it('seeds "Untitled {Type}" content for name-as-content Core types', async () => {
// These types' behaviors reject empty content, so the sidebar "+New" default
// of '' always failed validation; they must get a type-identifying placeholder.
const cases: Array<[string, string]> = [
['project', 'Untitled Project'],
['skill', 'Untitled Skill'],
['collection', 'Untitled Collection'],
['agent-guidance', 'Untitled Agent Guidance'],
['tool', 'Untitled Tool'],
];
for (const [typeId, expected] of cases) {
createNodeMock.mockClear();
await createSchemaInstance(typeId);
expect(createNodeMock.mock.calls[0][0]).toEqual(
expect.objectContaining({ nodeType: typeId, content: expected })
);
}
});

it('leaves body-content and custom types with empty content', async () => {
// Primitives ("start typing" state) and titleTemplate/custom types must be
// unaffected — empty content is correct for them.
for (const typeId of ['text', 'header', 'task', 'code-block', 'invoice']) {
createNodeMock.mockClear();
await createSchemaInstance(typeId);
expect(createNodeMock.mock.calls[0][0]).toEqual(
expect.objectContaining({ nodeType: typeId, content: '' })
);
}
});

it('returns the hydrated node fetched by the generated id', async () => {
const returned = await createSchemaInstance('invoice');

Expand Down