diff --git a/scripts/dev.ts b/scripts/dev.ts index a60564b3d..7387cbd42 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -265,7 +265,9 @@ function stopChildren(signal: NodeJS.Signals = 'SIGTERM'): void { } for (const cfg of processes) { - const child = Bun.spawn(cfg.command.split(' '), { + const args = cfg.command.split(' ') + if (args[0] === 'bun') args[0] = process.execPath + const child = Bun.spawn(args, { env: { ...process.env, ...cfg.env }, stdin: 'inherit', stdout: 'inherit', diff --git a/scripts/lib/freePort.ts b/scripts/lib/freePort.ts index d7f51e3b7..6953e18e7 100644 --- a/scripts/lib/freePort.ts +++ b/scripts/lib/freePort.ts @@ -22,6 +22,7 @@ interface PortHolder { * holding process's `comm` name. Empty array when the port is free. */ function findPortHolders(port: number): PortHolder[] { + if (process.platform === 'win32') return [] const lsof = Bun.spawnSync( ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { stdout: 'pipe', stderr: 'ignore' }, @@ -107,8 +108,14 @@ export async function ensurePortFree( const holders = findPortHolders(port) if (holders.length === 0) { - // Port is busy but we couldn't enumerate the holder (rare — usually - // a permissions issue on lsof). Fall through to the manual message. + if (process.platform === 'win32') { + // On Windows we can't enumerate holders easily; just wait a bit + // and re-probe in case a previous dev server is shutting down. + for (let i = 0; i < 30; i++) { + if (probePort(port) === 'free') return + Bun.spawnSync(['cmd', '/c', 'timeout', '/t', '1', '/nobreak', '>nul', '2>&1']) + } + } log(`Port ${port} (${name}) is in use, but the holding process could not be identified.`) log(`Run \`lsof -i :${port} -P -n\` to inspect it manually.`) process.exit(1) diff --git a/server/ai/tools/site/systemPrompt.ts b/server/ai/tools/site/systemPrompt.ts index b8548fdd0..5fb8df003 100644 --- a/server/ai/tools/site/systemPrompt.ts +++ b/server/ai/tools/site/systemPrompt.ts @@ -68,6 +68,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 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/src/__tests__/agent/agentSlice.test.ts b/src/__tests__/agent/agentSlice.test.ts index cb91026e3..2e9499cf8 100644 --- a/src/__tests__/agent/agentSlice.test.ts +++ b/src/__tests__/agent/agentSlice.test.ts @@ -35,6 +35,7 @@ function freshAgentState() { agentActiveModelId: null, agentContextTokens: null, agentConversations: [], + agentDraftMentions: [], hasUnsavedChanges: false, }) @@ -507,8 +508,8 @@ describe('sendAgentMessage — request lifecycle', () => { }) describe('conversation reset key-set', () => { - // All three reset paths must clear the SAME six keys — agentContextTokens and - // agentError have each silently drifted out of one copy in the past. + // All three reset paths must clear the SAME seven keys — agentContextTokens, + // agentError, and agentDraftMentions have each silently drifted out in the past. const RESET_SNAPSHOT = { agentMessages: [], agentError: null, @@ -516,6 +517,7 @@ describe('conversation reset key-set', () => { agentActiveCredentialId: null, agentActiveModelId: null, agentContextTokens: null, + agentDraftMentions: [], } function seedDirtyConversation() { @@ -527,6 +529,7 @@ describe('conversation reset key-set', () => { agentActiveCredentialId: 'cred-1', agentActiveModelId: 'model-1', agentContextTokens: 4096, + agentDraftMentions: [{ nodeId: 'abc123', label: 'Layer abc123' }], }) } @@ -539,10 +542,11 @@ describe('conversation reset key-set', () => { agentActiveCredentialId: s.agentActiveCredentialId, agentActiveModelId: s.agentActiveModelId, agentContextTokens: s.agentContextTokens, + agentDraftMentions: s.agentDraftMentions, } } - it('startNewAgentConversation resets the full six-key set (incl. agentContextTokens)', () => { + it('startNewAgentConversation resets the full seven-key set (incl. agentContextTokens)', () => { seedDirtyConversation() useEditorStore.getState().startNewAgentConversation() expect(pickResetKeys()).toEqual(RESET_SNAPSHOT) @@ -781,3 +785,44 @@ describe('setAgentProvider', () => { expect(useEditorStore.getState().agentError).toBeNull() }) }) + +// --------------------------------------------------------------------------- +// 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__/dom-panel/layerNodeContextMenu.test.tsx b/src/__tests__/dom-panel/layerNodeContextMenu.test.tsx index 4330ab396..ce8f7c0c1 100644 --- a/src/__tests__/dom-panel/layerNodeContextMenu.test.tsx +++ b/src/__tests__/dom-panel/layerNodeContextMenu.test.tsx @@ -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[0]) + } + + function renderAiChatMenu(nodeId = 'a') { + return render( + , + ) + } + + 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: 'Layer a' }, + ]) + 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[0]) + renderAiChatMenu('b') + + fireEvent.click(screen.getByRole('menuitem', { name: /add to ai chat/i })) + + expect(useEditorStore.getState().agentDraftMentions).toEqual([ + { nodeId: 'a', label: 'Layer a' }, + { nodeId: 'b', label: 'Layer b' }, + ]) + expect(useEditorStore.getState().isAgentOpen).toBe(true) + }) +}) diff --git a/src/admin/pages/site/agent/agentSlice.ts b/src/admin/pages/site/agent/agentSlice.ts index 3458b6939..e0276bc98 100644 --- a/src/admin/pages/site/agent/agentSlice.ts +++ b/src/admin/pages/site/agent/agentSlice.ts @@ -42,6 +42,7 @@ export type { AgentSlice, AgentSliceConfig } from './agentSliceTypes' import type { AgentBridgeRuntime, AgentMessage, + AgentMessageMention, AgentRequestBody, AgentTextStreamSink, } from './types' @@ -124,6 +125,8 @@ type ConversationResetKeys = | 'agentActiveCredentialId' | 'agentActiveModelId' | 'agentContextTokens' + | 'agentDraftMentions' + | 'agentMentionLabels' function conversationResetState(): Pick { return { @@ -133,6 +136,8 @@ function conversationResetState(): Pick { agentActiveCredentialId: null, agentActiveModelId: null, agentContextTokens: null, + agentDraftMentions: [], + agentMentionLabels: {}, } } @@ -242,6 +247,8 @@ export function createAgentSlice( agentActiveModelId: null, agentConversations: [], agentContextTokens: null, + agentDraftMentions: [], + agentMentionLabels: {}, // ── UI actions ─────────────────────────────────────────────────────────── openAgent() { @@ -258,6 +265,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() { _abortController?.abort() _abortController = null @@ -390,7 +411,7 @@ export function createAgentSlice( }, // ── sendAgentMessage ───────────────────────────────────────────────────── - async sendAgentMessage(content) { + async sendAgentMessage(content, mentions?: AgentMessageMention[]) { if (get().isAgentStreaming) return // one request at a time const userMsg: AgentMessage = { @@ -398,6 +419,7 @@ export function createAgentSlice( role: 'user', blocks: [{ kind: 'text', text: content }], timestamp: Date.now(), + mentions, } const assistantId = nanoid() @@ -413,6 +435,10 @@ export function createAgentSlice( state.agentMessages.push(assistantMsg) state.agentError = null state.isAgentStreaming = true + // Register mention labels so they survive node deletion + for (const m of mentions ?? []) { + state.agentMentionLabels[m.nodeId] = m.label + } }) _abortController = new AbortController() @@ -435,7 +461,21 @@ export function createAgentSlice( return } - const body: AgentRequestBody = { conversationId, prompt: content, snapshot } + // Build machine-readable prompt: replace human mention labels with + // Layer so the AI knows these are layer references. + let machinePrompt = content + for (const mention of mentions ?? []) { + machinePrompt = machinePrompt.replace(mention.label, `Layer ${mention.nodeId}`) + } + + // Instruct the AI to respond with the same Layer format + // so the UI can render them as clickable mention pills. + 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` + : '' + + const body: AgentRequestBody = { conversationId, prompt: instruction + machinePrompt, snapshot } const res = await fetch(`/admin/api/ai/chat/${config.scope}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/admin/pages/site/agent/agentSliceTypes.ts b/src/admin/pages/site/agent/agentSliceTypes.ts index 91efa50ac..b8c8bb1e2 100644 --- a/src/admin/pages/site/agent/agentSliceTypes.ts +++ b/src/admin/pages/site/agent/agentSliceTypes.ts @@ -28,6 +28,11 @@ export interface AgentSliceConfig { readonly noProviderMessage?: string } +export interface AgentDraftMention { + nodeId: string + label: string +} + export interface AgentSlice { isAgentOpen: boolean isAgentStreaming: boolean @@ -38,11 +43,23 @@ export interface AgentSlice { agentActiveModelId: string | null agentConversations: ConversationView[] agentContextTokens: number | null + /** + * Mention queue for the agent composer. Set by "Add to AI Chat" actions + * from the canvas / layers panel; consumed once by AgentComposer then + * cleared. Each entry carries the layer's nodeId and display label. + */ + agentDraftMentions: AgentDraftMention[] + /** + * Accumulated nodeId → human label registry across the conversation. + * Survives node deletion so scanned assistant mentions can still be + * rendered as clickable pills with friendly names after a node is gone. + */ + agentMentionLabels: Record openAgent(): void closeAgent(): void toggleAgent(): void - sendAgentMessage(content: string): Promise + sendAgentMessage(content: string, mentions?: import('./types').AgentMessageMention[]): Promise abortAgent(): void clearAgentMessages(): void loadAgentConversations(): Promise @@ -51,6 +68,15 @@ export interface AgentSlice { deleteAgentConversation(id: string): Promise setAgentProvider(credentialId: string, modelId: string): Promise loadScopeDefault(): Promise + /** + * Queue layer mentions into the agent composer and open the panel so the + * user can finish their prompt. Used by "Add to AI Chat" flows. + */ + stageAgentMentions(mentions: AgentDraftMention[]): void + /** + * Clear the mention queue after the composer has consumed it. + */ + clearAgentDraftMentions(): void } export type EditorStoreSet = Parameters>[0] diff --git a/src/admin/pages/site/agent/index.ts b/src/admin/pages/site/agent/index.ts index d62a98f97..199ddbb39 100644 --- a/src/admin/pages/site/agent/index.ts +++ b/src/admin/pages/site/agent/index.ts @@ -9,7 +9,7 @@ // Slice factory + its public contract. export { createAgentSlice } from './agentSlice' -export type { AgentSlice, AgentSliceConfig } from './agentSliceTypes' +export type { AgentSlice, AgentSliceConfig, AgentDraftMention } from './agentSliceTypes' // Site-editor wiring (scope, snapshot, dispatcher) handed to the factory. export { siteAgentSliceConfig } from './agentSliceConfig.site' diff --git a/src/admin/pages/site/agent/mentionLabel.ts b/src/admin/pages/site/agent/mentionLabel.ts new file mode 100644 index 000000000..526427cf8 --- /dev/null +++ b/src/admin/pages/site/agent/mentionLabel.ts @@ -0,0 +1,46 @@ +/** + * MentionLabel — compute a human-readable label and a tag-derived color key + * for a layer mention pill. + * + * The label is what the user sees; the colorKey is what drives the pill + * accent so it matches the DOM tree's `` badge colour. + */ +import { registry } from '@core/module-engine' +import { + getNodeDisplayName, + getNodeHtmlTag, + getNodeClassNames, +} from '@core/page-tree' +import { useEditorStore } from '@site/store/store' + +export interface MentionLabelResult { + label: string + colorKey: string +} + +export function getMentionLabelForNode(nodeId: string): MentionLabelResult { + const state = useEditorStore.getState() + const site = state.site + const activePageId = state.activePageId + const page = site?.pages.find((p) => p.id === activePageId) + const node = page?.nodes[nodeId] + + if (!node) { + return { label: nodeId, colorKey: nodeId } + } + + const def = registry.get(node.moduleId) + const classNames = getNodeClassNames(node, site?.styleRules) + const htmlTag = getNodeHtmlTag(node, def) + + // Priority: class selector chip → html tag badge → module display name + if (classNames.length > 0) { + return { label: `.${classNames.join('.')}`, colorKey: htmlTag ?? classNames[0] } + } + if (htmlTag) { + return { label: `<${htmlTag}>`, colorKey: htmlTag } + } + + const displayName = getNodeDisplayName(node, def, site?.visualComponents) + return { label: displayName, colorKey: displayName } +} diff --git a/src/admin/pages/site/agent/streamEvents.ts b/src/admin/pages/site/agent/streamEvents.ts index 002ebd6e5..8b9a947fb 100644 --- a/src/admin/pages/site/agent/streamEvents.ts +++ b/src/admin/pages/site/agent/streamEvents.ts @@ -34,6 +34,7 @@ import type { ServerStreamEvent, } from './types' import { getErrorMessage } from '@core/utils/errorMessage' +import { getMentionLabelForNode } from './mentionLabel' // --------------------------------------------------------------------------- // Stream-event schema @@ -182,6 +183,20 @@ export async function processStreamEvent( if (inputAsRecord) existing.toolCall.params = inputAsRecord return } + // Resolve a friendly display label now, while the node still exists. + const nodeId = + typeof inputAsRecord?.nodeId === 'string' + ? (inputAsRecord.nodeId as string) + : undefined + let displayLabel: string | undefined + if (nodeId) { + try { + displayLabel = getMentionLabelForNode(nodeId).label + } catch { + /* ignore */ + } + } + msg.blocks.push({ kind: 'toolCall', toolCall: { @@ -191,8 +206,13 @@ export async function processStreamEvent( params: inputAsRecord ?? {}, result: null, status: 'pending', + displayLabel, }, }) + // Register label so AI response scanning can resolve it after deletion + if (nodeId && displayLabel) { + state.agentMentionLabels[nodeId] = displayLabel + } }) break } diff --git a/src/admin/pages/site/agent/types.ts b/src/admin/pages/site/agent/types.ts index 50a944f04..463261443 100644 --- a/src/admin/pages/site/agent/types.ts +++ b/src/admin/pages/site/agent/types.ts @@ -159,6 +159,12 @@ export interface AgentToolCall { * agent looked at; never persisted — it rehydrates empty after a reload. */ screenshotDataUrl?: string + /** + * Human-readable label resolved at creation time (e.g. ".icon"). + * Cached so tool-call rows still show a friendly name after the node + * has been deleted. Populated for node-related tools only. + */ + displayLabel?: string } /** @@ -171,11 +177,22 @@ type AgentMessageBlock = | { kind: 'text'; text: string } | { kind: 'toolCall'; toolCall: AgentToolCall } +export interface AgentMessageMention { + nodeId: string + label: string +} + export interface AgentMessage { id: string role: 'user' | 'assistant' blocks: AgentMessageBlock[] timestamp: number + /** + * Layer mentions embedded in this message, purely for client-side + * rendering. Not sent to the server — populated from the composer's + * DOM when the user sends a message, or extracted from assistant text. + */ + mentions?: AgentMessageMention[] } // --------------------------------------------------------------------------- diff --git a/src/admin/pages/site/canvas/BreakpointSelectionOverlay.tsx b/src/admin/pages/site/canvas/BreakpointSelectionOverlay.tsx index fdb774065..3e297ebe7 100644 --- a/src/admin/pages/site/canvas/BreakpointSelectionOverlay.tsx +++ b/src/admin/pages/site/canvas/BreakpointSelectionOverlay.tsx @@ -80,6 +80,7 @@ import { cn } from '@ui/cn' import { CopyPlusSolidIcon } from 'pixel-art-icons/icons/copy-plus-solid' import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' import { HandGrabSolidIcon } from 'pixel-art-icons/icons/hand-grab-solid' +import { SparklesSolidIcon } from 'pixel-art-icons/icons/sparkles-solid' import { CanvasViewportActionsContext } from './CanvasContexts' import { CanvasInsertModuleButton } from './CanvasInsertModuleButton' import { useCanvasReorderDrag } from './useCanvasReorderDrag' @@ -139,6 +140,13 @@ function deleteSelectedLayers() { state.clearSelection() } +function addSelectedLayersToAiChat() { + const ids = useEditorStore.getState().selectedNodeIds + if (ids.length === 0) return + const mentions = ids.map((id) => ({ nodeId: id, label: `Layer ${id}` })) + useEditorStore.getState().stageAgentMentions(mentions) +} + export function BreakpointSelectionOverlay({ breakpointId, viewportRef, @@ -389,6 +397,17 @@ export function BreakpointSelectionOverlay({ +