Skip to content
Open
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
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
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([])
})
})
4 changes: 4 additions & 0 deletions src/__tests__/architecture/module-size-budgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ const GRANDFATHERED: Record<string, number> = {
'src/admin/pages/site/panels/TypographyPanel/FontsSection/AddGoogleFontDialog.tsx': 751,
'src/core/markdown/markdownDocument.ts': 748,
'src/admin/pages/dashboard/DashboardPage.tsx': 732,
// Grew past CEILING while adding the layer-mention queue and label registry to
// the agent slice. Extract conversation-reset and credential helpers to
// graduate this entry.
'src/admin/pages/site/agent/agentSlice.ts': 742,
}

// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/canvas/selectionToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ describe('canvas selection toolbar', () => {
expect(labels).toEqual([
'Drag selected layers',
'Insert module',
'Add selected layers to AI chat',
'Duplicate selected layers',
'Delete selected layers',
])
Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/dom-panel/layerNodeContextMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -864,3 +864,88 @@ describe('LayerNodeContextMenu — multi-delete confirmation', () => {
expect(pageAfterConfirm?.nodes.c).toBeDefined()
})
})

describe('LayerNodeContextMenu — Add to AI chat', () => {
function setupAiChatPage() {
localStorage.clear()
const home = makePage({
id: 'page-ai',
title: 'Home',
slug: 'index',
rootNodeId: 'root',
nodes: {
root: makeNode({ id: 'root', moduleId: 'base.body', children: ['a', 'b'] }),
a: makeNode({ id: 'a', moduleId: 'base.text' }),
b: makeNode({ id: 'b', moduleId: 'base.button' }),
},
})
useEditorStore.setState({
site: makeSite({ pages: [home], files: [], visualComponents: [] }),
activePageId: 'page-ai',
selectedNodeId: 'a',
selectedNodeIds: ['a'],
hoveredNodeId: null,
activeDocument: null,
isAgentOpen: false,
agentDraftMentions: [],
_historyPast: [],
_historyFuture: [],
canUndo: false,
canRedo: false,
hasUnsavedChanges: false,
} as Parameters<typeof useEditorStore.setState>[0])
}

function renderAiChatMenu(nodeId = 'a') {
return render(
<LayerNodeContextMenu
x={100}
y={200}
nodeId={nodeId}
onClose={noop}
onDelete={noop}
onDuplicate={noop}
onRename={noop}
onWrapInContainer={noop}
onCopy={noop}
onCut={noop}
onPaste={noop}
/>,
)
}

it('renders the "Add to AI chat" menu item', () => {
setupAiChatPage()
renderAiChatMenu()
expect(screen.getByRole('menuitem', { name: /add to ai chat/i })).toBeDefined()
})

it('stages a single node id as the agent draft and opens the panel', () => {
setupAiChatPage()
renderAiChatMenu('a')

fireEvent.click(screen.getByRole('menuitem', { name: /add to ai chat/i }))

expect(useEditorStore.getState().agentDraftMentions).toEqual([
{ nodeId: 'a', label: '<p>' },
])
expect(useEditorStore.getState().isAgentOpen).toBe(true)
})

it('stages multiple selected node ids when right-clicking a multi-selection', () => {
setupAiChatPage()
useEditorStore.setState({
selectedNodeId: 'b',
selectedNodeIds: ['a', 'b'],
} as Parameters<typeof useEditorStore.setState>[0])
renderAiChatMenu('b')

fireEvent.click(screen.getByRole('menuitem', { name: /add to ai chat/i }))

expect(useEditorStore.getState().agentDraftMentions).toEqual([
{ nodeId: 'a', label: '<p>' },
{ nodeId: 'b', label: '<button>' },
])
expect(useEditorStore.getState().isAgentOpen).toBe(true)
})
})
4 changes: 4 additions & 0 deletions src/__tests__/panels/agentPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ function createAgentStore(overrides: Partial<AgentSlice> = {}) {
isAgentConversationPending: false,
isAgentProviderPending: false,
agentComposerEpoch: 0,
agentDraftMentions: [],
agentMentionLabels: {},
openAgent: () => set({ isAgentOpen: true }),
closeAgent: () => set({ isAgentOpen: false }),
toggleAgent: () => set((state) => ({ isAgentOpen: !state.isAgentOpen })),
Expand All @@ -184,6 +186,8 @@ function createAgentStore(overrides: Partial<AgentSlice> = {}) {
set({ agentActiveCredentialId: credentialId, agentActiveModelId: modelId, agentError: null })
},
loadScopeDefault: async () => {},
stageAgentMentions: () => {},
clearAgentDraftMentions: () => set({ agentDraftMentions: [] }),
...overrides,
}))
}
Expand Down
51 changes: 49 additions & 2 deletions src/admin/pages/site/agent/agentSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type {
import type {
AgentBridgeRuntime,
AgentMessage,
AgentMessageMention,
AgentTextStreamSink,
} from './types'
import { getErrorMessage } from '@core/utils/errorMessage'
Expand Down Expand Up @@ -148,6 +149,8 @@ type ConversationResetKeys =
| 'agentActiveModelId'
| 'agentUsage'
| 'agentComposerEpoch'
| 'agentDraftMentions'
| 'agentMentionLabels'

function emptyConversationUsage(): AgentConversationUsage {
return {
Expand All @@ -171,6 +174,8 @@ function conversationResetState(agentComposerEpoch: number): Pick<AgentSlice, Co
agentActiveModelId: null,
agentUsage: emptyConversationUsage(),
agentComposerEpoch,
agentDraftMentions: [],
agentMentionLabels: {},
}
}

Expand Down Expand Up @@ -289,6 +294,8 @@ export function createAgentSlice(
isAgentConversationPending: false,
isAgentProviderPending: false,
agentComposerEpoch: 0,
agentDraftMentions: [],
agentMentionLabels: {},

// ── UI actions ───────────────────────────────────────────────────────────
openAgent() {
Expand All @@ -305,6 +312,20 @@ export function createAgentSlice(
})
},

stageAgentMentions(mentions) {
set((state) => {
state.agentDraftMentions.push(...mentions)
for (const m of mentions) {
state.agentMentionLabels[m.nodeId] = m.label
}
state.isAgentOpen = true
})
},

clearAgentDraftMentions() {
set({ agentDraftMentions: [] })
},

abortAgent() {
if (_abortController) _abortController.abort()
else set({ isAgentStreaming: false })
Expand Down Expand Up @@ -524,7 +545,7 @@ export function createAgentSlice(
},

// ── sendAgentMessage ─────────────────────────────────────────────────────
async sendAgentMessage(content) {
async sendAgentMessage(content, mentions?: AgentMessageMention[]) {
if (
get().isAgentStreaming
|| get().isAgentConversationPending
Expand All @@ -547,6 +568,7 @@ export function createAgentSlice(
}
: { ...block }),
timestamp: Date.now(),
mentions,
}

const assistantId = nanoid()
Expand Down Expand Up @@ -605,7 +627,28 @@ export function createAgentSlice(
}
}

const body: AiChatRequestBody = { conversationId, content: [...content], snapshot }
// Build machine-readable content: replace human mention labels with
// Layer <nodeId> so the AI knows these are layer references. Keep
// image blocks untouched.
const hasMentions = (mentions ?? []).length > 0
const instruction = hasMentions
? `Write layer references exactly as: the word Layer, a space, then the raw id with no extra characters. Example: Layer b3zJlOcL1. Never wrap ids in angle brackets or backticks.\n\n`
: ''
let instructionInjected = false
const machineContent = content.map((block) => {
if (block.kind !== 'text') return block
let text = block.text
for (const mention of mentions ?? []) {
text = text.replaceAll(mention.label, `Layer ${mention.nodeId}`)
}
if (!instructionInjected && hasMentions) {
text = instruction + text
instructionInjected = true
}
return { ...block, text }
})

const body: AiChatRequestBody = { conversationId, content: machineContent, snapshot }
const res = await fetch(`/admin/api/ai/chat/${config.scope}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -624,6 +667,10 @@ export function createAgentSlice(
set((state) => {
state.agentMessages.push(userMsg)
state.agentMessages.push(assistantMsg)
// Register mention labels so they survive node deletion.
for (const m of mentions ?? []) {
state.agentMentionLabels[m.nodeId] = m.label
}
})
if (!res.body) throw new Error('Agent response has no body')

Expand Down
Loading
Loading