From f49390888881d16d948b48a59cb5c5e46522e893 Mon Sep 17 00:00:00 2001 From: KalPop Date: Thu, 2 Jul 2026 21:39:05 +0100 Subject: [PATCH 1/5] fix(scripts): Windows dev server port probing with cmd timeout fallback Adds a Windows-specific retry loop using cmd /c timeout when the port probing helper cannot identify the holding process. This avoids false positives when a previous dev server is still shutting down. --- scripts/dev.ts | 4 +++- scripts/lib/freePort.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) 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) From 029207a635bdd880423119ea0fbd5b1da1393830 Mon Sep 17 00:00:00 2001 From: KalPop Date: Thu, 2 Jul 2026 22:21:37 +0100 Subject: [PATCH 2/5] feat: clickable layer mention pills in AI chat composer and thread --- server/ai/tools/site/systemPrompt.ts | 1 + src/__tests__/agent/agentSlice.test.ts | 51 +++- .../dom-panel/layerNodeContextMenu.test.tsx | 85 ++++++ src/admin/pages/site/agent/agentSlice.ts | 18 +- src/admin/pages/site/agent/agentSliceTypes.ts | 22 +- src/admin/pages/site/agent/index.ts | 2 +- src/admin/pages/site/agent/types.ts | 11 + .../canvas/BreakpointSelectionOverlay.tsx | 19 ++ .../site/panels/AgentPanel/AgentComposer.tsx | 135 ++++++++++ .../panels/AgentPanel/AgentPanel.module.css | 56 ++++ .../site/panels/AgentPanel/AgentPanel.tsx | 92 +------ .../site/panels/AgentPanel/RichTextBubble.tsx | 250 ++++++++++++++++++ .../panels/DomPanel/LayerNodeContextMenu.tsx | 13 + 13 files changed, 671 insertions(+), 84 deletions(-) create mode 100644 src/admin/pages/site/panels/AgentPanel/AgentComposer.tsx create mode 100644 src/admin/pages/site/panels/AgentPanel/RichTextBubble.tsx 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..887add329 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,7 @@ type ConversationResetKeys = | 'agentActiveCredentialId' | 'agentActiveModelId' | 'agentContextTokens' + | 'agentDraftMentions' function conversationResetState(): Pick { return { @@ -133,6 +135,7 @@ function conversationResetState(): Pick { agentActiveCredentialId: null, agentActiveModelId: null, agentContextTokens: null, + agentDraftMentions: [], } } @@ -242,6 +245,7 @@ export function createAgentSlice( agentActiveModelId: null, agentConversations: [], agentContextTokens: null, + agentDraftMentions: [], // ── UI actions ─────────────────────────────────────────────────────────── openAgent() { @@ -258,6 +262,17 @@ export function createAgentSlice( }) }, + stageAgentMentions(mentions) { + set((state) => { + state.agentDraftMentions.push(...mentions) + state.isAgentOpen = true + }) + }, + + clearAgentDraftMentions() { + set({ agentDraftMentions: [] }) + }, + abortAgent() { _abortController?.abort() _abortController = null @@ -390,7 +405,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 +413,7 @@ export function createAgentSlice( role: 'user', blocks: [{ kind: 'text', text: content }], timestamp: Date.now(), + mentions, } const assistantId = nanoid() diff --git a/src/admin/pages/site/agent/agentSliceTypes.ts b/src/admin/pages/site/agent/agentSliceTypes.ts index 91efa50ac..0593fec2d 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,17 @@ 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[] 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 +62,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/types.ts b/src/admin/pages/site/agent/types.ts index 50a944f04..32af1717b 100644 --- a/src/admin/pages/site/agent/types.ts +++ b/src/admin/pages/site/agent/types.ts @@ -171,11 +171,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({ +