Skip to content

Commit d4fe0b9

Browse files
committed
feat(agent): layer mentions and hybrid DOM-native node support
- Clickable layer mention pills in AI chat composer and thread - Human-readable layer mention labels with tag-derived colors - AI response mention scanning with machine-readable prompts - Cached tool-call labels and conversation mention registry - Lossless HTML-node roundtrip (hybrid DOM-native nodes) - Windows dev server port probing with cmd timeout fallback
1 parent 61fcebc commit d4fe0b9

107 files changed

Lines changed: 3618 additions & 1213 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,7 @@ docs/superpowers/
8383
.factory/
8484
.trae/
8585
.windsurf/
86+
87+
# Runtime PID file written by the MCP relay daemon
88+
relay-daemon.pid
89+

server/ai/tools/site/systemPrompt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Templates (CMS layouts):
6969
7070
Notes:
7171
- Use real ids from the suffix or prior tool results — never invent ids. Class refs accept id OR name.
72+
- 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.
7273
- 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.
7374
- On tool error: read the message and retry with corrected input.
7475

server/ai/tools/site/writeTools.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
ReplaceNodeHtmlInputSchema,
2929
DeleteNodeInputSchema,
3030
UpdateNodePropsInputSchema,
31+
UpdateDomNodeInputSchema,
3132
MoveNodeInputSchema,
3233
RenameNodeInputSchema,
3334
DuplicateNodeInputSchema,
@@ -152,6 +153,16 @@ const updateNodePropsTool: AiTool = {
152153
inputSchema: UpdateNodePropsInputSchema,
153154
}
154155

156+
const updateDomNodeTool: AiTool = {
157+
name: 'site_update_dom_node',
158+
scope: 'site',
159+
execution: 'browser',
160+
requiredCapabilities: SITE_CONTENT_CAPS,
161+
description:
162+
'Update a DOM-native node (a raw HTML element with no module — e.g. <figure>, <blockquote>, <li>, <mark>). 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.',
163+
inputSchema: UpdateDomNodeInputSchema,
164+
}
165+
155166
const moveNodeTool: AiTool = {
156167
name: 'site_move_node',
157168
scope: 'site',
@@ -417,6 +428,7 @@ export const siteWriteTools: AiTool[] = [
417428
replaceNodeHtmlTool,
418429
deleteNodeTool,
419430
updateNodePropsTool,
431+
updateDomNodeTool,
420432
moveNodeTool,
421433
renameNodeTool,
422434
duplicateNodeTool,

server/db/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,6 @@ export interface DbClient {
3232
unsafe<Row = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<DbResult<Row>>
3333
transaction<T>(fn: (tx: DbClient) => Promise<T>): Promise<T>
3434
readonly dialect: Dialect
35+
/** Close the underlying database connection. Only implemented by SQLite. */
36+
close?(): void
3537
}

server/db/sqlite.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,5 +158,9 @@ export function createSqliteClient(filename: string): DbClient {
158158
return result
159159
}
160160

161+
fn.close = () => {
162+
db.close()
163+
}
164+
161165
return Object.assign(fn, { dialect: 'sqlite' as const })
162166
}

server/handlers/cms/pageDiff.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,18 @@ function diffNode(
162162
requireChange(capabilities, 'style', `${nodePath}.breakpointOverrides`, 'breakpoint overrides changed')
163163
}
164164

165+
// DOM-native node fields (moduleId === ''). `tag` is structural (changing
166+
// element type), `attributes` and `textContent` are content edits.
167+
if (!deepEqual(previous.tag, next.tag)) {
168+
requireChange(capabilities, 'structure', `${nodePath}.tag`, 'DOM tag changed')
169+
}
170+
if (!deepEqual(previous.attributes, next.attributes)) {
171+
requireChange(capabilities, 'content', `${nodePath}.attributes`, 'DOM attributes changed')
172+
}
173+
if (!deepEqual(previous.textContent, next.textContent)) {
174+
requireChange(capabilities, 'content', `${nodePath}.textContent`, 'DOM text content changed')
175+
}
176+
165177
diffNodeProps(capabilities, nodePath, previous, next)
166178
}
167179

src/__tests__/agent/agentSlice.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import '@modules/base'
1616
// Test helpers
1717
// ---------------------------------------------------------------------------
1818

19+
function nodeModuleId(n: unknown): string {
20+
const node = n as { moduleId: string; moduleOverlay?: { moduleId: string } | null }
21+
return node.moduleOverlay?.moduleId ?? node.moduleId
22+
}
23+
1924
function freshAgentState() {
2025
useEditorStore.setState({
2126
site: null,
@@ -48,6 +53,7 @@ function freshAgentState() {
4853
isAgentProviderPending: false,
4954
agentComposerEpoch: 0,
5055
agentConversations: [],
56+
agentDraftMentions: [],
5157
hasUnsavedChanges: false,
5258
})
5359

@@ -322,7 +328,7 @@ describe('processStreamEvent — toolRequest dispatches to executor', () => {
322328
expect(result.ok).toBe(true)
323329

324330
const page = useEditorStore.getState().site!.pages[0]
325-
expect(Object.values(page.nodes).some((n) => n.moduleId === 'base.text')).toBe(true)
331+
expect(Object.values(page.nodes).some((n) => nodeModuleId(n) === 'base.text')).toBe(true)
326332
})
327333

328334
it('reports an error result when the tool input is invalid', async () => {
@@ -1007,6 +1013,7 @@ describe('loadAgentConversation — rehydration', () => {
10071013
describe('conversation reset key-set', () => {
10081014
// All three reset paths clear the same conversation keys and advance the
10091015
// composer epoch so local text/image drafts cannot cross conversations.
1016+
// Layer-mention state is also reset so stale drafts don't carry over.
10101017
const RESET_SNAPSHOT = {
10111018
agentMessages: [],
10121019
agentError: null,
@@ -1024,6 +1031,8 @@ describe('conversation reset key-set', () => {
10241031
costUsd: 0,
10251032
},
10261033
agentComposerEpoch: 1,
1034+
agentDraftMentions: [],
1035+
agentMentionLabels: {},
10271036
}
10281037

10291038
function seedDirtyConversation() {
@@ -1045,6 +1054,8 @@ describe('conversation reset key-set', () => {
10451054
cacheCreationTokens: 500,
10461055
costUsd: 0.42,
10471056
},
1057+
agentDraftMentions: [{ nodeId: 'abc123', label: 'Layer abc123' }],
1058+
agentMentionLabels: { abc123: 'Layer abc123' },
10481059
})
10491060
}
10501061

@@ -1058,6 +1069,8 @@ describe('conversation reset key-set', () => {
10581069
agentActiveModelId: s.agentActiveModelId,
10591070
agentUsage: s.agentUsage,
10601071
agentComposerEpoch: s.agentComposerEpoch,
1072+
agentDraftMentions: s.agentDraftMentions,
1073+
agentMentionLabels: s.agentMentionLabels,
10611074
}
10621075
}
10631076

@@ -1549,3 +1562,44 @@ describe('setAgentProvider', () => {
15491562
}
15501563
})
15511564
})
1565+
1566+
// ---------------------------------------------------------------------------
1567+
// stageAgentMentions — "Add to AI Chat" staging
1568+
// ---------------------------------------------------------------------------
1569+
1570+
describe('stageAgentMentions', () => {
1571+
it('stages mentions and opens the agent panel', () => {
1572+
freshAgentState()
1573+
useEditorStore.setState({ isAgentOpen: false, agentDraftMentions: [] })
1574+
1575+
useEditorStore.getState().stageAgentMentions([{ nodeId: 'abc123', label: 'Layer abc123' }])
1576+
1577+
expect(useEditorStore.getState().agentDraftMentions).toEqual([{ nodeId: 'abc123', label: 'Layer abc123' }])
1578+
expect(useEditorStore.getState().isAgentOpen).toBe(true)
1579+
})
1580+
1581+
it('appends to existing mentions', () => {
1582+
freshAgentState()
1583+
useEditorStore.setState({ agentDraftMentions: [{ nodeId: 'old', label: 'Layer old' }] })
1584+
1585+
useEditorStore.getState().stageAgentMentions([
1586+
{ nodeId: 'abc123', label: 'Layer abc123' },
1587+
{ nodeId: 'def456', label: 'Layer def456' },
1588+
])
1589+
1590+
expect(useEditorStore.getState().agentDraftMentions).toEqual([
1591+
{ nodeId: 'old', label: 'Layer old' },
1592+
{ nodeId: 'abc123', label: 'Layer abc123' },
1593+
{ nodeId: 'def456', label: 'Layer def456' },
1594+
])
1595+
})
1596+
1597+
it('clears mentions via clearAgentDraftMentions', () => {
1598+
freshAgentState()
1599+
useEditorStore.setState({ agentDraftMentions: [{ nodeId: 'abc123', label: 'Layer abc123' }] })
1600+
1601+
useEditorStore.getState().clearAgentDraftMentions()
1602+
1603+
expect(useEditorStore.getState().agentDraftMentions).toEqual([])
1604+
})
1605+
})

src/__tests__/agent/executor.test.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ import '@modules/base'
2121
// Store reset helper
2222
// ---------------------------------------------------------------------------
2323

24+
/** Effective moduleId — moduleOverlay.moduleId takes precedence over node.moduleId. */
25+
function nodeModuleId(n: unknown): string {
26+
const node = n as { moduleId: string; moduleOverlay?: { moduleId: string } | null }
27+
return node.moduleOverlay?.moduleId ?? node.moduleId
28+
}
29+
30+
/** Effective props — moduleOverlay.props takes precedence over node.props. */
31+
function nodeProps(n: unknown): Record<string, unknown> {
32+
const node = n as { props: Record<string, unknown>; moduleOverlay?: { props: Record<string, unknown> } | null }
33+
return node.moduleOverlay?.props ?? node.props
34+
}
35+
2436
function freshStore() {
2537
useEditorStore.setState({
2638
site: null,
@@ -125,9 +137,9 @@ describe('executeAgentTool — insertHtml', () => {
125137
const nodes = Object.values(page.nodes)
126138

127139
// The section element maps to base.container
128-
expect(nodes.some((n) => n.moduleId === 'base.container')).toBe(true)
140+
expect(nodes.some((n) => nodeModuleId(n) === 'base.container')).toBe(true)
129141
// The h1 and p elements map to base.text
130-
expect(nodes.some((n) => n.moduleId === 'base.text')).toBe(true)
142+
expect(nodes.some((n) => nodeModuleId(n) === 'base.text')).toBe(true)
131143

132144
// The inserted root (section) is wired as a child of the page root
133145
const root = page.nodes[rootId]
@@ -661,7 +673,7 @@ describe('executeAgentTool — replaceNodeHtml', () => {
661673
const pageAfter = useEditorStore.getState().site!.pages[0]
662674
// The container node is preserved as the parent
663675
expect(pageAfter.nodes[containerId]).toBeDefined()
664-
expect(pageAfter.nodes[containerId].moduleId).toBe('base.container')
676+
expect(nodeModuleId(pageAfter.nodes[containerId])).toBe('base.container')
665677

666678
// Children are rebuilt from the new HTML (h1 + p = 2 nodes)
667679
expect(pageAfter.nodes[containerId].children).toHaveLength(2)
@@ -670,7 +682,7 @@ describe('executeAgentTool — replaceNodeHtml', () => {
670682
const childNodes = pageAfter.nodes[containerId].children.map(
671683
(id) => pageAfter.nodes[id],
672684
)
673-
expect(childNodes.every((n) => n.moduleId === 'base.text')).toBe(true)
685+
expect(childNodes.every((n) => nodeModuleId(n) === 'base.text')).toBe(true)
674686
})
675687

676688
it('returns failure when nodeId does not exist', async () => {
@@ -901,7 +913,7 @@ describe('executeAgentTool — updateNodeProps', () => {
901913
const nodeId = expectNodeIds(insertResult)[0]
902914
await executeAgentTool('site_update_node_props', { nodeId, patch: { text: 'New' } })
903915
const page = useEditorStore.getState().site!.pages[0]
904-
expect(page.nodes[nodeId].props.text).toBe('New')
916+
expect(nodeProps(page.nodes[nodeId]).text).toBe('New')
905917
})
906918

907919
it('rejects updateNodeProps with breakpointId for content props', async () => {
@@ -927,7 +939,7 @@ describe('executeAgentTool — updateNodeProps', () => {
927939
expectToolError(result)
928940
expect(result.error ?? '').toContain('breakpointOverridable')
929941
const page = useEditorStore.getState().site!.pages[0]
930-
expect(page.nodes[nodeId].props.text).toBe('Desktop copy')
942+
expect(nodeProps(page.nodes[nodeId]).text).toBe('Desktop copy')
931943
expect(page.nodes[nodeId].breakpointOverrides.mobile).toBeUndefined()
932944
})
933945

@@ -1611,7 +1623,7 @@ describe('executeAgentTool — insertHtml <instatic-outlet>', () => {
16111623
const nodeIds = expectNodeIds(result)
16121624
expect(nodeIds.length).toBe(3)
16131625
const nodes = useEditorStore.getState().site!.pages[0].nodes
1614-
const moduleIds = nodeIds.map((id) => nodes[id].moduleId)
1626+
const moduleIds = nodeIds.map((id) => nodeModuleId(nodes[id]))
16151627
expect(moduleIds).toContain('base.outlet')
16161628
})
16171629
})
@@ -1638,8 +1650,8 @@ describe('executeAgentTool — duplicateNode', () => {
16381650
expect(root.children).toEqual([sourceId, clonedNodeId])
16391651
// Cloned props match source.
16401652
const cloned = useEditorStore.getState().site!.pages[0].nodes[clonedNodeId]
1641-
expect(cloned.props.text).toBe('Original')
1642-
expect(cloned.props.tag).toBe('h2')
1653+
expect(nodeProps(cloned).text).toBe('Original')
1654+
expect(nodeProps(cloned).tag).toBe('h2')
16431655
})
16441656

16451657
it('produces N clones in arrival order when count is set', async () => {
@@ -1708,7 +1720,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
17081720
patch: { richtext: '<p>Hello</p><script>alert(1)</script>' },
17091721
})
17101722
const page = useEditorStore.getState().site!.pages[0]
1711-
const stored = page.nodes[nodeId].props.richtext as string
1723+
const stored = nodeProps(page.nodes[nodeId]).richtext as string
17121724
expect(stored).not.toContain('<script>')
17131725
expect(stored).not.toContain('alert(1)')
17141726
expect(stored).toContain('Hello')
@@ -1723,7 +1735,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
17231735
patch: { richtext: '<img src=x onerror=alert(1)>' },
17241736
})
17251737
const page = useEditorStore.getState().site!.pages[0]
1726-
const stored = page.nodes[nodeId].props.richtext as string
1738+
const stored = nodeProps(page.nodes[nodeId]).richtext as string
17271739
expect(stored).not.toContain('onerror')
17281740
expect(stored).not.toContain('alert(1)')
17291741
})
@@ -1737,7 +1749,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
17371749
patch: { bodyHtml: '<a href="javascript:alert(1)">click</a>' },
17381750
})
17391751
const page = useEditorStore.getState().site!.pages[0]
1740-
const stored = page.nodes[nodeId].props.bodyHtml as string
1752+
const stored = nodeProps(page.nodes[nodeId]).bodyHtml as string
17411753
expect(stored).not.toContain('javascript:')
17421754
})
17431755

@@ -1751,7 +1763,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
17511763
patch: { richtext: safeHtml },
17521764
})
17531765
const page = useEditorStore.getState().site!.pages[0]
1754-
const stored = page.nodes[nodeId].props.richtext as string
1766+
const stored = nodeProps(page.nodes[nodeId]).richtext as string
17551767
expect(stored).toContain('Bold')
17561768
expect(stored).toContain('italic')
17571769
})
@@ -1765,7 +1777,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
17651777
patch: { text: 'Cats & Dogs' },
17661778
})
17671779
const page = useEditorStore.getState().site!.pages[0]
1768-
expect(page.nodes[nodeId].props.text).toBe('Cats & Dogs')
1780+
expect(nodeProps(page.nodes[nodeId]).text).toBe('Cats & Dogs')
17691781
})
17701782
})
17711783

@@ -1795,10 +1807,10 @@ describe('executeAgentTool — insertHtml unsafe HTML stripping (Constraint #299
17951807

17961808
const page = useEditorStore.getState().site!.pages[0]
17971809
// A base.text node was created from the <p>
1798-
const addedNodes = Object.values(page.nodes).filter((n) => n.moduleId === 'base.text')
1810+
const addedNodes = Object.values(page.nodes).filter((n) => nodeModuleId(n) === 'base.text')
17991811
expect(addedNodes.length).toBeGreaterThan(0)
18001812
// No node props contain the script content
1801-
const withScript = addedNodes.find((n) => String(n.props.text).includes('alert'))
1813+
const withScript = addedNodes.find((n) => String(nodeProps(n).text).includes('alert'))
18021814
expect(withScript).toBeUndefined()
18031815
})
18041816
})

src/__tests__/architecture/agent-tool-surface.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ describe('agent-tool-surface gate', () => {
9090
expect(toolNames).toContain('site_clear_page_template')
9191
})
9292

93-
it('total tool count is 29 (document, HTML, node, CSS, code asset, page, template, token, and snapshot tools)', () => {
94-
expect(toolNames).toHaveLength(29)
93+
it('total tool count is 30 (document, HTML, node, CSS, code asset, page, template, token, and snapshot tools)', () => {
94+
expect(toolNames).toHaveLength(30)
9595
})
9696
})

src/__tests__/architecture/ai-tool-schema-ssot.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
ReplaceNodeHtmlInputSchema,
4040
DeleteNodeInputSchema,
4141
UpdateNodePropsInputSchema,
42+
UpdateDomNodeInputSchema,
4243
MoveNodeInputSchema,
4344
RenameNodeInputSchema,
4445
DuplicateNodeInputSchema,
@@ -74,6 +75,7 @@ const EXPECTED_SCHEMA_BY_TOOL = {
7475
site_replace_node_html: ReplaceNodeHtmlInputSchema,
7576
site_delete_node: DeleteNodeInputSchema,
7677
site_update_node_props: UpdateNodePropsInputSchema,
78+
site_update_dom_node: UpdateDomNodeInputSchema,
7779
site_move_node: MoveNodeInputSchema,
7880
site_rename_node: RenameNodeInputSchema,
7981
site_duplicate_node: DuplicateNodeInputSchema,

0 commit comments

Comments
 (0)