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
4 changes: 3 additions & 1 deletion scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 9 additions & 2 deletions scripts/lib/freePort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions server/ai/tools/site/systemPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
51 changes: 48 additions & 3 deletions src/__tests__/agent/agentSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function freshAgentState() {
agentActiveModelId: null,
agentContextTokens: null,
agentConversations: [],
agentDraftMentions: [],
hasUnsavedChanges: false,
})

Expand Down Expand Up @@ -507,15 +508,16 @@ 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,
agentConversationId: null,
agentActiveCredentialId: null,
agentActiveModelId: null,
agentContextTokens: null,
agentDraftMentions: [],
}

function seedDirtyConversation() {
Expand All @@ -527,6 +529,7 @@ describe('conversation reset key-set', () => {
agentActiveCredentialId: 'cred-1',
agentActiveModelId: 'model-1',
agentContextTokens: 4096,
agentDraftMentions: [{ nodeId: 'abc123', label: 'Layer abc123' }],
})
}

Expand All @@ -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)
Expand Down Expand Up @@ -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([])
})
})
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: '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<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: 'Layer a' },
{ nodeId: 'b', label: 'Layer b' },
])
expect(useEditorStore.getState().isAgentOpen).toBe(true)
})
})
44 changes: 42 additions & 2 deletions src/admin/pages/site/agent/agentSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type { AgentSlice, AgentSliceConfig } from './agentSliceTypes'
import type {
AgentBridgeRuntime,
AgentMessage,
AgentMessageMention,
AgentRequestBody,
AgentTextStreamSink,
} from './types'
Expand Down Expand Up @@ -124,6 +125,8 @@ type ConversationResetKeys =
| 'agentActiveCredentialId'
| 'agentActiveModelId'
| 'agentContextTokens'
| 'agentDraftMentions'
| 'agentMentionLabels'

function conversationResetState(): Pick<AgentSlice, ConversationResetKeys> {
return {
Expand All @@ -133,6 +136,8 @@ function conversationResetState(): Pick<AgentSlice, ConversationResetKeys> {
agentActiveCredentialId: null,
agentActiveModelId: null,
agentContextTokens: null,
agentDraftMentions: [],
agentMentionLabels: {},
}
}

Expand Down Expand Up @@ -242,6 +247,8 @@ export function createAgentSlice(
agentActiveModelId: null,
agentConversations: [],
agentContextTokens: null,
agentDraftMentions: [],
agentMentionLabels: {},

// ── UI actions ───────────────────────────────────────────────────────────
openAgent() {
Expand All @@ -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
Expand Down Expand Up @@ -390,14 +411,15 @@ export function createAgentSlice(
},

// ── sendAgentMessage ─────────────────────────────────────────────────────
async sendAgentMessage(content) {
async sendAgentMessage(content, mentions?: AgentMessageMention[]) {
if (get().isAgentStreaming) return // one request at a time

const userMsg: AgentMessage = {
id: nanoid(),
role: 'user',
blocks: [{ kind: 'text', text: content }],
timestamp: Date.now(),
mentions,
}

const assistantId = nanoid()
Expand All @@ -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()
Expand All @@ -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 <nodeId> 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 <nodeId> 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' },
Expand Down
28 changes: 27 additions & 1 deletion src/admin/pages/site/agent/agentSliceTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export interface AgentSliceConfig {
readonly noProviderMessage?: string
}

export interface AgentDraftMention {
nodeId: string
label: string
}

export interface AgentSlice {
isAgentOpen: boolean
isAgentStreaming: boolean
Expand All @@ -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<string, string>

openAgent(): void
closeAgent(): void
toggleAgent(): void
sendAgentMessage(content: string): Promise<void>
sendAgentMessage(content: string, mentions?: import('./types').AgentMessageMention[]): Promise<void>
abortAgent(): void
clearAgentMessages(): void
loadAgentConversations(): Promise<void>
Expand All @@ -51,6 +68,15 @@ export interface AgentSlice {
deleteAgentConversation(id: string): Promise<void>
setAgentProvider(credentialId: string, modelId: string): Promise<void>
loadScopeDefault(): Promise<void>
/**
* 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<EditorStoreSliceCreator<AgentSlice>>[0]
Expand Down
2 changes: 1 addition & 1 deletion src/admin/pages/site/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading