diff --git a/.gitignore b/.gitignore index c2c797642..b70599151 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,7 @@ docs/superpowers/ .factory/ .trae/ .windsurf/ + +# Runtime PID file written by the MCP relay daemon +relay-daemon.pid + diff --git a/server/ai/tools/site/systemPrompt.ts b/server/ai/tools/site/systemPrompt.ts index a99002fa3..7e2345cc2 100644 --- a/server/ai/tools/site/systemPrompt.ts +++ b/server/ai/tools/site/systemPrompt.ts @@ -69,6 +69,7 @@ Templates (CMS layouts): Notes: - Use real ids from the suffix or prior tool results — never invent ids. Class refs accept id OR name. +- When the user references a specific layer by ID (e.g. "Layer abc123" or "Layers abc123, def456"), extract those nodeIds and use them directly in your tool calls — do not ask the user to describe the element again. - Browser write-tool success data uses explicit keys: cssRulesCreated/cssRulesUpdated/cssRulesDeleted/cssPropertiesRemoved for site_apply_css, pageId for site_add_page/site_duplicate_page, nodeId/nodeIds for site_duplicate_node, and nodeIds for HTML inserts. - On tool error: read the message and retry with corrected input. diff --git a/server/ai/tools/site/writeTools.ts b/server/ai/tools/site/writeTools.ts index 384d1398c..22c1b33cb 100644 --- a/server/ai/tools/site/writeTools.ts +++ b/server/ai/tools/site/writeTools.ts @@ -28,6 +28,7 @@ import { ReplaceNodeHtmlInputSchema, DeleteNodeInputSchema, UpdateNodePropsInputSchema, + UpdateDomNodeInputSchema, MoveNodeInputSchema, RenameNodeInputSchema, DuplicateNodeInputSchema, @@ -152,6 +153,16 @@ const updateNodePropsTool: AiTool = { inputSchema: UpdateNodePropsInputSchema, } +const updateDomNodeTool: AiTool = { + name: 'site_update_dom_node', + scope: 'site', + execution: 'browser', + requiredCapabilities: SITE_CONTENT_CAPS, + description: + 'Update a DOM-native node (a raw HTML element with no module — e.g.
,
,
  • , ). Pass `tag` to change the element type, `attributes` to replace all HTML attributes (pass null to clear), or `textContent` to set/clear leaf text (pass null to clear). Use site_update_node_props for module-based nodes instead.', + inputSchema: UpdateDomNodeInputSchema, +} + const moveNodeTool: AiTool = { name: 'site_move_node', scope: 'site', @@ -417,6 +428,7 @@ export const siteWriteTools: AiTool[] = [ replaceNodeHtmlTool, deleteNodeTool, updateNodePropsTool, + updateDomNodeTool, moveNodeTool, renameNodeTool, duplicateNodeTool, diff --git a/server/db/client.ts b/server/db/client.ts index 7837833d4..cad7e16ae 100644 --- a/server/db/client.ts +++ b/server/db/client.ts @@ -32,4 +32,6 @@ export interface DbClient { unsafe>(sql: string, params?: unknown[]): Promise> transaction(fn: (tx: DbClient) => Promise): Promise readonly dialect: Dialect + /** Close the underlying database connection. Only implemented by SQLite. */ + close?(): void } diff --git a/server/db/sqlite.ts b/server/db/sqlite.ts index eb9249dbf..d3a850721 100644 --- a/server/db/sqlite.ts +++ b/server/db/sqlite.ts @@ -158,5 +158,9 @@ export function createSqliteClient(filename: string): DbClient { return result } + fn.close = () => { + db.close() + } + return Object.assign(fn, { dialect: 'sqlite' as const }) } diff --git a/server/handlers/cms/pageDiff.ts b/server/handlers/cms/pageDiff.ts index d0063089e..ecde45728 100644 --- a/server/handlers/cms/pageDiff.ts +++ b/server/handlers/cms/pageDiff.ts @@ -162,6 +162,18 @@ function diffNode( requireChange(capabilities, 'style', `${nodePath}.breakpointOverrides`, 'breakpoint overrides changed') } + // DOM-native node fields (moduleId === ''). `tag` is structural (changing + // element type), `attributes` and `textContent` are content edits. + if (!deepEqual(previous.tag, next.tag)) { + requireChange(capabilities, 'structure', `${nodePath}.tag`, 'DOM tag changed') + } + if (!deepEqual(previous.attributes, next.attributes)) { + requireChange(capabilities, 'content', `${nodePath}.attributes`, 'DOM attributes changed') + } + if (!deepEqual(previous.textContent, next.textContent)) { + requireChange(capabilities, 'content', `${nodePath}.textContent`, 'DOM text content changed') + } + diffNodeProps(capabilities, nodePath, previous, next) } diff --git a/src/__tests__/agent/agentSlice.test.ts b/src/__tests__/agent/agentSlice.test.ts index 6e6d6c6d6..ef7a25f52 100644 --- a/src/__tests__/agent/agentSlice.test.ts +++ b/src/__tests__/agent/agentSlice.test.ts @@ -16,6 +16,11 @@ import '@modules/base' // Test helpers // --------------------------------------------------------------------------- +function nodeModuleId(n: unknown): string { + const node = n as { moduleId: string; moduleOverlay?: { moduleId: string } | null } + return node.moduleOverlay?.moduleId ?? node.moduleId +} + function freshAgentState() { useEditorStore.setState({ site: null, @@ -48,6 +53,7 @@ function freshAgentState() { isAgentProviderPending: false, agentComposerEpoch: 0, agentConversations: [], + agentDraftMentions: [], hasUnsavedChanges: false, }) @@ -322,7 +328,7 @@ describe('processStreamEvent — toolRequest dispatches to executor', () => { expect(result.ok).toBe(true) const page = useEditorStore.getState().site!.pages[0] - expect(Object.values(page.nodes).some((n) => n.moduleId === 'base.text')).toBe(true) + expect(Object.values(page.nodes).some((n) => nodeModuleId(n) === 'base.text')).toBe(true) }) it('reports an error result when the tool input is invalid', async () => { @@ -1007,6 +1013,7 @@ describe('loadAgentConversation — rehydration', () => { describe('conversation reset key-set', () => { // All three reset paths clear the same conversation keys and advance the // composer epoch so local text/image drafts cannot cross conversations. + // Layer-mention state is also reset so stale drafts don't carry over. const RESET_SNAPSHOT = { agentMessages: [], agentError: null, @@ -1024,6 +1031,8 @@ describe('conversation reset key-set', () => { costUsd: 0, }, agentComposerEpoch: 1, + agentDraftMentions: [], + agentMentionLabels: {}, } function seedDirtyConversation() { @@ -1045,6 +1054,8 @@ describe('conversation reset key-set', () => { cacheCreationTokens: 500, costUsd: 0.42, }, + agentDraftMentions: [{ nodeId: 'abc123', label: 'Layer abc123' }], + agentMentionLabels: { abc123: 'Layer abc123' }, }) } @@ -1058,6 +1069,8 @@ describe('conversation reset key-set', () => { agentActiveModelId: s.agentActiveModelId, agentUsage: s.agentUsage, agentComposerEpoch: s.agentComposerEpoch, + agentDraftMentions: s.agentDraftMentions, + agentMentionLabels: s.agentMentionLabels, } } @@ -1549,3 +1562,44 @@ describe('setAgentProvider', () => { } }) }) + +// --------------------------------------------------------------------------- +// stageAgentMentions — "Add to AI Chat" staging +// --------------------------------------------------------------------------- + +describe('stageAgentMentions', () => { + it('stages mentions and opens the agent panel', () => { + freshAgentState() + useEditorStore.setState({ isAgentOpen: false, agentDraftMentions: [] }) + + useEditorStore.getState().stageAgentMentions([{ nodeId: 'abc123', label: 'Layer abc123' }]) + + expect(useEditorStore.getState().agentDraftMentions).toEqual([{ nodeId: 'abc123', label: 'Layer abc123' }]) + expect(useEditorStore.getState().isAgentOpen).toBe(true) + }) + + it('appends to existing mentions', () => { + freshAgentState() + useEditorStore.setState({ agentDraftMentions: [{ nodeId: 'old', label: 'Layer old' }] }) + + useEditorStore.getState().stageAgentMentions([ + { nodeId: 'abc123', label: 'Layer abc123' }, + { nodeId: 'def456', label: 'Layer def456' }, + ]) + + expect(useEditorStore.getState().agentDraftMentions).toEqual([ + { nodeId: 'old', label: 'Layer old' }, + { nodeId: 'abc123', label: 'Layer abc123' }, + { nodeId: 'def456', label: 'Layer def456' }, + ]) + }) + + it('clears mentions via clearAgentDraftMentions', () => { + freshAgentState() + useEditorStore.setState({ agentDraftMentions: [{ nodeId: 'abc123', label: 'Layer abc123' }] }) + + useEditorStore.getState().clearAgentDraftMentions() + + expect(useEditorStore.getState().agentDraftMentions).toEqual([]) + }) +}) diff --git a/src/__tests__/agent/executor.test.ts b/src/__tests__/agent/executor.test.ts index 32494c578..6bbb03308 100644 --- a/src/__tests__/agent/executor.test.ts +++ b/src/__tests__/agent/executor.test.ts @@ -21,6 +21,18 @@ import '@modules/base' // Store reset helper // --------------------------------------------------------------------------- +/** Effective moduleId — moduleOverlay.moduleId takes precedence over node.moduleId. */ +function nodeModuleId(n: unknown): string { + const node = n as { moduleId: string; moduleOverlay?: { moduleId: string } | null } + return node.moduleOverlay?.moduleId ?? node.moduleId +} + +/** Effective props — moduleOverlay.props takes precedence over node.props. */ +function nodeProps(n: unknown): Record { + const node = n as { props: Record; moduleOverlay?: { props: Record } | null } + return node.moduleOverlay?.props ?? node.props +} + function freshStore() { useEditorStore.setState({ site: null, @@ -125,9 +137,9 @@ describe('executeAgentTool — insertHtml', () => { const nodes = Object.values(page.nodes) // The section element maps to base.container - expect(nodes.some((n) => n.moduleId === 'base.container')).toBe(true) + expect(nodes.some((n) => nodeModuleId(n) === 'base.container')).toBe(true) // The h1 and p elements map to base.text - expect(nodes.some((n) => n.moduleId === 'base.text')).toBe(true) + expect(nodes.some((n) => nodeModuleId(n) === 'base.text')).toBe(true) // The inserted root (section) is wired as a child of the page root const root = page.nodes[rootId] @@ -661,7 +673,7 @@ describe('executeAgentTool — replaceNodeHtml', () => { const pageAfter = useEditorStore.getState().site!.pages[0] // The container node is preserved as the parent expect(pageAfter.nodes[containerId]).toBeDefined() - expect(pageAfter.nodes[containerId].moduleId).toBe('base.container') + expect(nodeModuleId(pageAfter.nodes[containerId])).toBe('base.container') // Children are rebuilt from the new HTML (h1 + p = 2 nodes) expect(pageAfter.nodes[containerId].children).toHaveLength(2) @@ -670,7 +682,7 @@ describe('executeAgentTool — replaceNodeHtml', () => { const childNodes = pageAfter.nodes[containerId].children.map( (id) => pageAfter.nodes[id], ) - expect(childNodes.every((n) => n.moduleId === 'base.text')).toBe(true) + expect(childNodes.every((n) => nodeModuleId(n) === 'base.text')).toBe(true) }) it('returns failure when nodeId does not exist', async () => { @@ -901,7 +913,7 @@ describe('executeAgentTool — updateNodeProps', () => { const nodeId = expectNodeIds(insertResult)[0] await executeAgentTool('site_update_node_props', { nodeId, patch: { text: 'New' } }) const page = useEditorStore.getState().site!.pages[0] - expect(page.nodes[nodeId].props.text).toBe('New') + expect(nodeProps(page.nodes[nodeId]).text).toBe('New') }) it('rejects updateNodeProps with breakpointId for content props', async () => { @@ -927,7 +939,7 @@ describe('executeAgentTool — updateNodeProps', () => { expectToolError(result) expect(result.error ?? '').toContain('breakpointOverridable') const page = useEditorStore.getState().site!.pages[0] - expect(page.nodes[nodeId].props.text).toBe('Desktop copy') + expect(nodeProps(page.nodes[nodeId]).text).toBe('Desktop copy') expect(page.nodes[nodeId].breakpointOverrides.mobile).toBeUndefined() }) @@ -1611,7 +1623,7 @@ describe('executeAgentTool — insertHtml ', () => { const nodeIds = expectNodeIds(result) expect(nodeIds.length).toBe(3) const nodes = useEditorStore.getState().site!.pages[0].nodes - const moduleIds = nodeIds.map((id) => nodes[id].moduleId) + const moduleIds = nodeIds.map((id) => nodeModuleId(nodes[id])) expect(moduleIds).toContain('base.outlet') }) }) @@ -1638,8 +1650,8 @@ describe('executeAgentTool — duplicateNode', () => { expect(root.children).toEqual([sourceId, clonedNodeId]) // Cloned props match source. const cloned = useEditorStore.getState().site!.pages[0].nodes[clonedNodeId] - expect(cloned.props.text).toBe('Original') - expect(cloned.props.tag).toBe('h2') + expect(nodeProps(cloned).text).toBe('Original') + expect(nodeProps(cloned).tag).toBe('h2') }) it('produces N clones in arrival order when count is set', async () => { @@ -1708,7 +1720,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint patch: { richtext: '

    Hello

    ' }, }) const page = useEditorStore.getState().site!.pages[0] - const stored = page.nodes[nodeId].props.richtext as string + const stored = nodeProps(page.nodes[nodeId]).richtext as string expect(stored).not.toContain('