Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f493908
fix(scripts): Windows dev server port probing with cmd timeout fallback
keypop3750 Jul 2, 2026
029207a
feat: clickable layer mention pills in AI chat composer and thread
keypop3750 Jul 2, 2026
73ef1ed
feat: human-readable layer mention labels with tag-derived colors
keypop3750 Jul 3, 2026
d7d568f
feat: AI response mention scanning with machine-readable prompts
keypop3750 Jul 3, 2026
f0f31ac
feat: cached tool-call labels + conversation mention registry
keypop3750 Jul 3, 2026
760a3e9
Merge upstream/main into feature/agent-layer-mentions; resolve script…
keypop3750 Jul 7, 2026
806d21a
feat: lossless HTML-node roundtrip (hybrid DOM-native nodes)
keypop3750 Jul 7, 2026
e52ee8b
Merge upstream/main into hybrid-dom-native-nodes
keypop3750 Jul 8, 2026
2f90ead
fix(siteImport): normalize and rewrite node.attributes and moduleOver…
keypop3750 Jul 8, 2026
41029af
fix: resolve Build, Typecheck, and Lint CI failures
keypop3750 Jul 8, 2026
56fb47b
fix: resolve architecture gate failures
keypop3750 Jul 9, 2026
3a6511f
fix: repair VC + HTML import regressions from hybrid DOM-native changes
keypop3750 Jul 9, 2026
7e318ea
Merge upstream/main into agent-layer-mentions and resolve conflicts
keypop3750 Jul 12, 2026
9770d8b
fix(lint): remove unused memo import from AgentPanel
keypop3750 Jul 12, 2026
17030b9
fix: resolve Build, Typecheck, and Lint CI failures (from hybrid-dom-…
keypop3750 Jul 12, 2026
8818fab
Merge hybrid-dom-native-nodes fixes for CI build, lint, and test fail…
keypop3750 Jul 12, 2026
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: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,7 @@ docs/superpowers/
.factory/
.trae/
.windsurf/

# Runtime PID file written by the MCP relay daemon
relay-daemon.pid

1 change: 1 addition & 0 deletions server/ai/tools/site/systemPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions server/ai/tools/site/writeTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
ReplaceNodeHtmlInputSchema,
DeleteNodeInputSchema,
UpdateNodePropsInputSchema,
UpdateDomNodeInputSchema,
MoveNodeInputSchema,
RenameNodeInputSchema,
DuplicateNodeInputSchema,
Expand Down Expand Up @@ -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. <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.',
inputSchema: UpdateDomNodeInputSchema,
}

const moveNodeTool: AiTool = {
name: 'site_move_node',
scope: 'site',
Expand Down Expand Up @@ -417,6 +428,7 @@ export const siteWriteTools: AiTool[] = [
replaceNodeHtmlTool,
deleteNodeTool,
updateNodePropsTool,
updateDomNodeTool,
moveNodeTool,
renameNodeTool,
duplicateNodeTool,
Expand Down
2 changes: 2 additions & 0 deletions server/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ export interface DbClient {
unsafe<Row = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<DbResult<Row>>
transaction<T>(fn: (tx: DbClient) => Promise<T>): Promise<T>
readonly dialect: Dialect
/** Close the underlying database connection. Only implemented by SQLite. */
close?(): void
}
4 changes: 4 additions & 0 deletions server/db/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,5 +158,9 @@ export function createSqliteClient(filename: string): DbClient {
return result
}

fn.close = () => {
db.close()
}

return Object.assign(fn, { dialect: 'sqlite' as const })
}
12 changes: 12 additions & 0 deletions server/handlers/cms/pageDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
56 changes: 55 additions & 1 deletion src/__tests__/agent/agentSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,6 +53,7 @@ function freshAgentState() {
isAgentProviderPending: false,
agentComposerEpoch: 0,
agentConversations: [],
agentDraftMentions: [],
hasUnsavedChanges: false,
})

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -1024,6 +1031,8 @@ describe('conversation reset key-set', () => {
costUsd: 0,
},
agentComposerEpoch: 1,
agentDraftMentions: [],
agentMentionLabels: {},
}

function seedDirtyConversation() {
Expand All @@ -1045,6 +1054,8 @@ describe('conversation reset key-set', () => {
cacheCreationTokens: 500,
costUsd: 0.42,
},
agentDraftMentions: [{ nodeId: 'abc123', label: 'Layer abc123' }],
agentMentionLabels: { abc123: 'Layer abc123' },
})
}

Expand All @@ -1058,6 +1069,8 @@ describe('conversation reset key-set', () => {
agentActiveModelId: s.agentActiveModelId,
agentUsage: s.agentUsage,
agentComposerEpoch: s.agentComposerEpoch,
agentDraftMentions: s.agentDraftMentions,
agentMentionLabels: s.agentMentionLabels,
}
}

Expand Down Expand Up @@ -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([])
})
})
44 changes: 28 additions & 16 deletions src/__tests__/agent/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
const node = n as { props: Record<string, unknown>; moduleOverlay?: { props: Record<string, unknown> } | null }
return node.moduleOverlay?.props ?? node.props
}

function freshStore() {
useEditorStore.setState({
site: null,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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()
})

Expand Down Expand Up @@ -1611,7 +1623,7 @@ describe('executeAgentTool — insertHtml <instatic-outlet>', () => {
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')
})
})
Expand All @@ -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 () => {
Expand Down Expand Up @@ -1708,7 +1720,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { richtext: '<p>Hello</p><script>alert(1)</script>' },
})
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('<script>')
expect(stored).not.toContain('alert(1)')
expect(stored).toContain('Hello')
Expand All @@ -1723,7 +1735,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { richtext: '<img src=x onerror=alert(1)>' },
})
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('onerror')
expect(stored).not.toContain('alert(1)')
})
Expand All @@ -1737,7 +1749,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { bodyHtml: '<a href="javascript:alert(1)">click</a>' },
})
const page = useEditorStore.getState().site!.pages[0]
const stored = page.nodes[nodeId].props.bodyHtml as string
const stored = nodeProps(page.nodes[nodeId]).bodyHtml as string
expect(stored).not.toContain('javascript:')
})

Expand All @@ -1751,7 +1763,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { richtext: safeHtml },
})
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).toContain('Bold')
expect(stored).toContain('italic')
})
Expand All @@ -1765,7 +1777,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { text: 'Cats & Dogs' },
})
const page = useEditorStore.getState().site!.pages[0]
expect(page.nodes[nodeId].props.text).toBe('Cats & Dogs')
expect(nodeProps(page.nodes[nodeId]).text).toBe('Cats & Dogs')
})
})

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

const page = useEditorStore.getState().site!.pages[0]
// A base.text node was created from the <p>
const addedNodes = Object.values(page.nodes).filter((n) => n.moduleId === 'base.text')
const addedNodes = Object.values(page.nodes).filter((n) => nodeModuleId(n) === 'base.text')
expect(addedNodes.length).toBeGreaterThan(0)
// No node props contain the script content
const withScript = addedNodes.find((n) => String(n.props.text).includes('alert'))
const withScript = addedNodes.find((n) => String(nodeProps(n).text).includes('alert'))
expect(withScript).toBeUndefined()
})
})
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/architecture/agent-tool-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('agent-tool-surface gate', () => {
expect(toolNames).toContain('site_clear_page_template')
})

it('total tool count is 29 (document, HTML, node, CSS, code asset, page, template, token, and snapshot tools)', () => {
expect(toolNames).toHaveLength(29)
it('total tool count is 30 (document, HTML, node, CSS, code asset, page, template, token, and snapshot tools)', () => {
expect(toolNames).toHaveLength(30)
})
})
2 changes: 2 additions & 0 deletions src/__tests__/architecture/ai-tool-schema-ssot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
ReplaceNodeHtmlInputSchema,
DeleteNodeInputSchema,
UpdateNodePropsInputSchema,
UpdateDomNodeInputSchema,
MoveNodeInputSchema,
RenameNodeInputSchema,
DuplicateNodeInputSchema,
Expand Down Expand Up @@ -74,6 +75,7 @@ const EXPECTED_SCHEMA_BY_TOOL = {
site_replace_node_html: ReplaceNodeHtmlInputSchema,
site_delete_node: DeleteNodeInputSchema,
site_update_node_props: UpdateNodePropsInputSchema,
site_update_dom_node: UpdateDomNodeInputSchema,
site_move_node: MoveNodeInputSchema,
site_rename_node: RenameNodeInputSchema,
site_duplicate_node: DuplicateNodeInputSchema,
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/architecture/error-boundary-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ describe('Error boundary coverage gate', () => {
it('main.tsx createRoot callbacks log via the shared logErrorChain helper', () => {
// Catches the regression where someone replaces logErrorChain with a raw
// `console.error(error)` and we lose the [<module>] prefix + cause chain.
const source = read(MAIN_FILE.replace(SRC_ROOT + '/', ''))
const source = read('admin/main.tsx')
expect(source).toMatch(/logErrorChain/)
expect(source).toMatch(/flattenErrorChain/)
})
Expand Down
Loading
Loading