feat(editor): Add agents sidebar navigation and chat-first builder UX (#28127)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin A 2026-04-13 19:15:52 +02:00 committed by GitHub
parent 67321a075f
commit aff4bf902d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2942 additions and 532 deletions

View File

@ -0,0 +1,326 @@
# AGENT-16 + AGENT-1: Home Screen & Creation Flow
**Tickets:** [AGENT-16](https://linear.app/n8n/issue/AGENT-16), [AGENT-1](https://linear.app/n8n/issue/AGENT-1)
**Branch:** `n8n-agents`
**Figma:** https://www.figma.com/design/PeFCNtBUBdtDNonFc9vmqR/Agents-Q2?node-id=0-1&p=f&m=dev
---
## Overview
These two tickets together transform the agent UX from the current code-editor-centric tabbed layout to the Figma-designed chat-first experience. AGENT-16 covers sidebar navigation and home screen entry points. AGENT-1 covers the agent creation flow and the main agent view (chat + settings sidebar).
---
## Part 1: AGENT-16 — Sidebar & Home Screen
### What Figma Shows
**Left sidebar** (see node `115:653`):
- Below "Shared with me", a new **"Agents"** section header with **"New"** badge
- Lists user's agents by name with emoji icons (e.g. "Darwin")
- **"+ New agent"** action at bottom of list
- Agents are visible regardless of which project is selected (cross-project)
- Tooltip/coachmark on first visit: "Create agents that you can chat to, run autonomously, or include in workflows"
**Home screen** (Overview page):
- "Create agent" available in the top-right **"Create workflow"** dropdown menu
- Agents tab added to project tabs (Workflows, Credentials, Executions, Variables, **Agents**)
### What Already Exists
- `ProjectNavigation.vue` — main sidebar with Overview, Personal, Shared, InstanceAI, Chat items
- `ProjectHeader.vue` — already has `ACTION_TYPES.AGENT` + "New Agent" button + dropdown item
- `ProjectTabs.vue` — dynamic tabs via `additionalTabs` prop from module descriptors
- `module.descriptor.ts` — agents module already registers "Agents" tab in `projectTabs.overview` and `projectTabs.project`
- `settingsStore.isModuleActive('agents')` — feature flag gate already used in ProjectHeader
- `listAllAgents` API — `GET /agents/v2?all=true` returns cross-project agent list
### What Needs to Change
#### 1.1 PostHog feature flag gating
**File:** `packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts`
The `agents` module registration should be gated behind PostHog flag `0XX_first_class_agents` (confirm index with team). When the flag is off, the module isn't registered, `settingsStore.isModuleActive('agents')` returns false, and all UI (sidebar, tabs, create button) is hidden.
#### 1.2 Add Agents section to `ProjectNavigation.vue`
**File:** `packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue`
Add between the Instance AI / Chat items and the "Projects" label:
```
"Agents" section header with "New" badge
├── Agent 1 (icon + name) → navigates to agent builder
├── Agent 2 (icon + name)
└── + New agent → navigates to new agent creation page
```
Implementation:
- Add computed `isAgentsAvailable` gated on `settingsStore.isModuleActive('agents')`
- Add computed `agentsList` that calls `listAllAgents` (fetched on mount, cached in a local ref)
- Render a section with `N8nText` header "Agents" + `N8nTag` "New" badge
- Map agents to `N8nMenuItem` items with route to `AGENT_BUILDER_VIEW`
- Add "+ New agent" item that routes to the new agent creation page (new route, see Part 2)
- Active state: highlight when current route is an agent builder with matching agentId
#### 1.3 Coachmark tooltip
- On first display of the Agents section, show a tooltip/popover: "Create agents that you can chat to, run autonomously, or include in workflows"
- Gate on a localStorage flag (e.g. `n8n_agents_coachmark_dismissed`)
- Use existing `useN8nLocalStorage` composable for persistence
- Dismiss on click or after navigating to an agent
#### 1.4 No changes needed (already done)
- "New Agent" in ProjectHeader create dropdown
- Agents tab in ProjectTabs (via module descriptor)
- Feature flag gating
---
## Part 2: AGENT-1 — Creation Flow & Agent View Redesign
### What Figma Shows
**A. "New agent" page** (nodes `115:1350`, `115:4136`):
- Header: "New agent" title, **"Create blank"** button top-right
- Center content: "Let's build something, [Name]" heading
- Text input: "Describe what you're working on..." with mic + send buttons
- Below input: **"Suggestions"** section with 3 template cards:
- Each card: emoji icon + bold name + description + tool icons on right
- e.g. "SEO Audit", "Recruiting Sourcer", "Inbox Sorter"
- Submitting a description → uses the AI builder to create the agent (existing `POST /build` endpoint)
**B. Agent home/chat view** (node `6:193`):
- **3-column layout**: left sidebar (app nav) | center (chat) | right (settings)
- Top bar: agent icon + "Sales Agent" name, settings toggle + activity toggle icons
- Center column:
- Agent icon (large) + name + "Add a description here" subtitle
- Chat input: "Send a message..." with attachment, mic, send buttons
- **"Recent"** section: list of past sessions with source icon + title + preview + timestamp
- Right column (**Settings sidebar**, toggled via icon):
- "Settings" header with Cancel + Save buttons
- "Unsaved changes" indicator
- **Model**: provider icon + model name + credential, with dropdown
- **Instructions**: text area with agent system prompt
- **Triggers** (collapsible): n8n Chat, Slack Mention, Schedule, Workflow — with "+" to add
- **Tools** (collapsible): list of tools with credential names, "+" to add, warning state for missing creds
- **Advanced** (collapsible): memory config, guardrails toggles, delete agent
**C. Agent chat conversation** (node `14:319`):
- Breadcrumb: "Sales Agent > Biggest churn risk"
- User messages right-aligned, agent messages left-aligned
- Tool calls: collapsible "5 tool calls" with list (icon + "Service → Action description")
- Message actions: copy, retry, more (...)
- Chat input pinned to bottom
### What Already Exists
- `AgentBuilderView.vue` — current tabbed layout: left sidebar (Code/Overview/Tools/Prompts/Memory/Evals/Integrations) + center panel + right chat panel
- `AgentChatPanel.vue` — SSE streaming chat with tool call display (functional)
- `AgentOverviewPanel.vue` — model selector, credential picker, instructions editor
- `AgentToolsPanel.vue` — tool management with workflow tools
- `AgentSidebar.vue` — left tab navigation (code-first tabs)
- All backend APIs for chat, schema, tools, integrations already exist
### What Needs to Change
#### 2.1 New route: Agent creation page
**File:** `packages/frontend/editor-ui/src/features/agents/module.descriptor.ts`
Add a new route:
```typescript
{
name: 'NEW_AGENT',
path: '/new-agent',
component: NewAgentView,
meta: { middleware: ['authenticated', 'custom'] },
}
```
**File:** `packages/frontend/editor-ui/src/features/agents/constants.ts`
Add: `export const NEW_AGENT_VIEW = 'new-agent';`
#### 2.2 New view: `NewAgentView.vue`
**File:** `packages/frontend/editor-ui/src/features/agents/views/NewAgentView.vue`
Layout:
- Uses the standard app layout (with main sidebar visible)
- Top bar: "New agent" title left, "Create blank" button right
- Center content (vertically centered, max-width ~600px):
- Heading: "Let's build something, [firstName]" (from `useUsersStore().currentUser`)
- Text area input: placeholder "Describe what you're working on...", mic button, send button
- "Suggestions" label
- 3 suggestion cards (hardcoded for P0, ties into AGENT-15):
- Icon + name (bold) + description + tool count icons
- Click → populates the text input with a pre-written prompt
Behavior:
- **"Create blank"** button: calls `createAgent()` API, navigates to `AGENT_BUILDER_VIEW`
- **Submit description**: calls `createAgent()`, then navigates to `AGENT_BUILDER_VIEW` with a query param like `?prompt=...` so the builder can auto-trigger the build endpoint
- **Click suggestion**: populates input, user can edit + submit
#### 2.3 Redesign `AgentBuilderView.vue` — chat-first layout
**File:** `packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue`
Current layout:
```
[AgentSidebar (tabs)] [Center (code/overview/tools/...)] [AgentChatPanel]
```
New layout (matching Figma):
```
[Center: Agent home + Chat] [Settings sidebar (togglable)]
```
Changes:
- Remove `AgentSidebar` (tab navigation) from the agent builder view
- Center column becomes the **chat-first view**:
- Agent header: icon + name (editable) + description (editable)
- Chat input (reuse `AgentChatPanel` internals or `ChatInputBase`)
- Recent sessions list (below chat input when no conversation is active)
- When in conversation: full chat thread replaces the home content
- Right column becomes the **Settings sidebar** (new component):
- Toggle visibility via header icons (settings icon + activity icon)
- Scrollable panel with all settings sections
Top bar:
- Left: agent icon + agent name (as breadcrumb: "Sales Agent" or "Sales Agent > Session title")
- Right: settings toggle icon, activity toggle icon
#### 2.4 New component: `AgentSettingsSidebar.vue`
**File:** `packages/frontend/editor-ui/src/features/agents/components/AgentSettingsSidebar.vue`
A right-hand panel (~340px wide) containing all agent configuration in a single scrollable view:
| Section | Content | Existing component to reuse |
|---------|---------|---------------------------|
| Header | "Settings" + Cancel/Save buttons + "Unsaved changes" | New |
| Model | Provider + model dropdown + credential picker | `AgentOverviewPanel` (extract model/credential parts) |
| Instructions | Text area for system prompt | `AgentOverviewPanel` (extract instructions part) |
| Triggers | Collapsible list + "+" button | New (P0: just list, no inline config) |
| Tools | Collapsible list with credential status + "+" button | `AgentToolsPanel` (simplified view) |
| Advanced | Collapsible: memory, guardrails, delete | `AgentMemoryPanel` parts, new guardrails toggles |
| Code | Collapsible accordion (collapsed by default), Monaco editor | `AgentCodeEditor` (existing) |
Behavior:
- Tracks local changes, shows "Unsaved changes" indicator
- Save: calls `patchAgentSchema()` + `updateAgent()` as needed
- Cancel: reverts to last-saved state
#### 2.5 New component: `AgentHomeContent.vue`
**File:** `packages/frontend/editor-ui/src/features/agents/components/AgentHomeContent.vue`
The center content when no chat session is active:
- Large agent icon (centered)
- Agent name (editable on click)
- "Add a description here" subtitle (editable on click)
- Chat input (`ChatInputBase` with attachment, mic, send)
- "Recent" section header with list/grid toggle icon
- Recent sessions list (each item: source icon + title + preview text + timestamp)
Note: Recent sessions requires a new API endpoint. For P0 MVP, this can show an empty state or be omitted if the sessions API isn't ready (depends on AGENT-9).
#### 2.6 Update `AgentChatPanel.vue` for inline use
Currently `AgentChatPanel` is a right-side panel. It needs to work as the center content when a conversation is active. Refactor to:
- Extract chat message thread into a reusable `AgentChatThread.vue`
- `AgentChatPanel` becomes a wrapper that can render in center-column mode (full width) vs. the old side-panel mode
- Add breadcrumb support in the top bar (agent name > session title)
#### 2.7 Update `ProjectHeader.vue` — "New Agent" action
Currently "New Agent" in the dropdown creates a blank agent and navigates to builder. Change to navigate to `NEW_AGENT_VIEW` instead, so users see the creation flow.
**File:** `packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue`
```typescript
[ACTION_TYPES.AGENT]: () => {
void router.push({ name: NEW_AGENT_VIEW });
},
```
---
## File Changes Summary
| Action | File | Description |
|--------|------|-------------|
| Modify | `ProjectNavigation.vue` | Add Agents section with list + coachmark |
| Create | `views/NewAgentView.vue` | "Let's build something" creation page |
| Create | `components/AgentSettingsSidebar.vue` | Right-hand settings panel |
| Create | `components/AgentHomeContent.vue` | Agent home (icon, chat input, recent) |
| Modify | `views/AgentBuilderView.vue` | Redesign to chat-first 2-column layout |
| Modify | `components/AgentChatPanel.vue` | Refactor for center-column use |
| Modify | `module.descriptor.ts` | Add NEW_AGENT route |
| Modify | `constants.ts` | Add NEW_AGENT_VIEW constant |
| Modify | `ProjectHeader.vue` | Route "New Agent" to creation page |
| Modify | `AgentOverviewPanel.vue` | Extract model/instructions for reuse in settings sidebar |
---
## Implementation TODO
### AGENT-16: Sidebar & Home Screen
- [ ] Gate agents module registration behind PostHog flag `0XX_first_class_agents` in `moduleInitializer.ts`
- [x] Add `isAgentsAvailable` computed + agent list fetch to `ProjectNavigation.vue`
- [x] Render Agents section header with "New" badge
- [x] Render agent list items with icons + names + navigation (global, cross-project)
- [x] Add "+ New agent" item routing to creation page
- [x] Add coachmark tooltip with localStorage dismissal
- [x] Active state highlighting for current agent
### AGENT-1: Creation Flow
- [x] Add `NEW_AGENT_VIEW` constant and route in module descriptor
- [x] Create `NewAgentView.vue` with heading, input, suggestions, "Create blank"
- [x] Hardcode 3 suggestion templates (SEO Audit, Recruiting Sourcer, Inbox Sorter)
- [x] "Create blank" creates agent + navigates to builder
- [x] Submit description creates agent + navigates to builder with prompt param
- [x] Update ProjectHeader to route "New Agent" action to creation page
### AGENT-1: Agent View Redesign
- [x] Create `AgentSettingsSidebar.vue` with Model, Instructions, Triggers, Tools, Advanced, Code sections
- [x] Code section: collapsible accordion (collapsed by default) wrapping `AgentCodeEditor`
- [x] Create `AgentHomeContent.vue` with agent icon/name/description + chat input + recent sessions placeholder
- [x] Recent sessions: show "No conversations yet" empty state
- [x] Redesign `AgentBuilderView.vue` to 2-column layout (center chat + right settings)
- [x] Add top bar with agent name, settings toggle, activity toggle — split header pattern (each column has own 56px header)
- [x] Refactor `AgentChatPanel.vue` for center-column rendering (inline mode)
- [x] ~~Extract reusable parts from `AgentOverviewPanel`~~ → Built model/instructions/credential directly in sidebar using ChatHub patterns (CredentialIcon, PROVIDER_CREDENTIAL_TYPE_MAP, credential setup flow)
- [x] Wire Save/Cancel in settings sidebar to schema API
- [x] Add "Unsaved changes" tracking (dirty state lifted to parent view)
### Additional work completed (not in original plan)
- [x] Dynamic provider list from `chatHubLLMProviderSchema` (not hardcoded)
- [x] Provider icons via `CredentialIcon` + `PROVIDER_CREDENTIAL_TYPE_MAP` (not copied SVGs)
- [x] ChatHub-style credential setup: auto-select single credential, open selector modal or creation modal
- [x] Model dropdown disabled when no credential is set
- [x] Provider ↔ catalog ID mapping (ChatHub camelCase ↔ SDK lowercase)
- [x] All static text uses i18n (30 keys in en.json)
- [x] CSS uses proper design tokens (`--background--surface`, `--background--surface--hover`)
- [x] Unit tests: constants, provider mapping, NewAgentView render
### Deferred (not blocking P0 launch)
- [ ] Recent sessions list (needs AGENT-9 execution logging API)
- [ ] Activity sidebar (Agent ID, Session ID, context, cost)
- [ ] Trigger inline configuration (n8n Chat, Schedule — Slack already works)
- [ ] Tool config modals (Slack channels, Workflow config)
- [ ] Breadcrumb navigation for chat sessions
- [ ] PostHog feature flag gating (needs backend + flag index confirmed with team)
---
## Decisions
1. **Agents in sidebar — global.** The sidebar shows all agents across all projects via `?all=true` API. The "Agents" tab within a project view shows only that project's agents (project-scoped via `listAgents`).
2. **Feature flag — PostHog `0XX_first_class_agents`.** A PostHog experiment flag controls whether the `agents` module is registered in `moduleInitializer.ts`. The `settingsStore.isModuleActive('agents')` gate stays as the UI-level check — it'll be falsy when the module isn't registered. The exact index prefix (e.g. `025`, `026`) should be confirmed with the team before implementation.
3. **Recent sessions — placeholder.** Show the "Recent" section header with an empty state: "No conversations yet — send a message to get started." This preserves layout structure and will be wired to real data when AGENT-9 lands.
4. **Code editor — collapsed accordion.** Keep the Monaco code editor accessible in the settings sidebar as a collapsible "Code" accordion section, collapsed by default. Will be hidden/removed closer to P0 release.

View File

@ -0,0 +1,169 @@
# Agents Q2 — Project Scope
**Branch:** `n8n-agents`
**Linear:** https://linear.app/n8n/project/agents-as-first-class-citizen-3bfa6b1e2bb8
**Figma:** https://www.figma.com/design/PeFCNtBUBdtDNonFc9vmqR/Agents-Q2?node-id=0-1&p=f&m=dev
**Target:** 2026-04-27
**Team:** Agent (Yehor Kardash lead)
## P0 Objectives
- Learn what agents users build to expand framework capabilities
- Nail down agent building UX
- MVP: create agents as first-class citizens, connect workflows as tools, invoke from within (workflows) and outside (chat) n8n
- Release: experiment to 5% cloud users (and/or self-hosted)
---
## P0 Linear Tickets
| Ticket | Title | Status | Assignee | Branch impl? |
|--------|-------|--------|----------|--------------|
| AGENT-1 | Creation flow UI | Ready for pick up | — | Partial (list + builder views exist, but not Figma design) |
| AGENT-2 | Integrate agent builder with InstanceAI | Ready for pick up | — | Partial (BuilderCard exists) |
| AGENT-3 | Agent <-> JSON conversion | Review | Yehor | Done (schema introspection + fromSchema + code gen) |
| AGENT-4 | Run agent code in sandbox for JSON schema | Review | Michael Drury | Done (AgentSecureRuntime + describeSecurely) |
| AGENT-5 | Allow running nodes as tools | In Progress | Yehor | Not yet |
| AGENT-6 | Allow running workflows as tools | Review | Yehor | Done (WorkflowToolFactory) |
| AGENT-7 | Chat integrations and triggers | Ready for pick up | — | Partial (Slack done, n8n Chat + Schedule not yet) |
| AGENT-8 | Default agent memory | In Progress | Yehor | Partial (N8NCheckpointStorage exists, full N8nMemory TBD) |
| AGENT-9 | Agent execution logging | Ready for pick up | — | Not yet |
| AGENT-10 | Support top N LLM providers | Ready for pick up | — | Partial (model catalog API exists, UI picker exists) |
| AGENT-11 | Top N memory integrations (Postgres, Redis) | Ready for pick up | — | Not yet |
| AGENT-12 | Agent evals | Ready for pick up | — | Partial (UI panel exists, backend eval runner TBD) |
| AGENT-13 | Create Agent node (workflow) | Ready for pick up | — | Not yet |
| AGENT-14 | "create-tool" tool | Ready for pick up | — | Not yet |
| AGENT-15 | Hardcoded starter templates | Ready for pick up | — | Not yet |
| AGENT-16 | Update home screen | Ready for pick up | — | Partial (agents tab in project header, not full sidebar) |
| AGENT-17 | Memory analysis / footprint | Parked | — | Not yet |
**Progress: ~7% (per Linear milestone)**
---
## Full Figma Scope (3 sections)
### 1. Using the Agent
- Left sidebar: "Agents" section with list + "+ New agent"
- Agent home: icon, name, description, chat input, recent sessions with source icons + timestamps
- Chat view: breadcrumb nav, user/agent messages, collapsible tool calls, message actions (copy/retry/more)
- Settings sidebar (right panel): Model picker, instructions, triggers (n8n Chat/Slack/Schedule), tools list with credentials, advanced section, cancel/save with unsaved indicator
- Activity sidebar: Agent ID, Session ID, model, context usage (tokens/%), cost
- Tools modal: search, connected tools with status, available tools catalog, per-tool gear
- Tool config modals: Slack (credential + channel picker), Workflow (workflow + trigger + message), generic pattern
- Workflow node: "Custom Agent" node in canvas with agent dropdown + prompt
### 2. Creating an Agent
- Via InstanceAI: chat-based creation, progress steps, agent card on completion
- Via "+ New agent": "Let's build something" landing, description input, suggestion cards (SEO Audit, Recruiting Sourcer, etc.), "Create blank" option
### 3. View Agent Session (WIP in Figma)
- Read-only session review with full tool call history
---
## What's Been Implemented
### Backend (`packages/cli/src/modules/agents/`)
**DB:** `agents` table (id, name, code, description, projectId, credentialId, provider, model, integrations JSON, schema JSON) + `agent_checkpoints` table
**API** (`/projects/:projectId/agents/v2`):
- CRUD: POST/GET/PATCH/DELETE agents
- GET `/:agentId/schema` / PATCH `/:agentId/schema` — introspect + update
- GET `/:agentId/credentials` — list user credentials
- GET `/catalog/models` — LLM provider/model catalog
- POST `/:agentId/chat` — SSE streaming chat execution
- POST `/:agentId/build` — SSE streaming AI builder (code generation)
- POST `/:agentId/integrations/connect|disconnect`, GET `/status` — Slack integration
- POST `/:agentId/webhooks/:platform` — inbound Slack webhooks
**Services:**
- `AgentsService` — CRUD, runtime caching (TTL 30m), schema caching (TTL 10m), `executeForChat()`, `resumeForChat()`, `compileIsolated()`
- `AgentsBuilderService` — AI builds/modifies agent code via streaming
- `AgentSecureRuntime` — sandboxed user code execution
- `WorkflowToolFactory` — n8n workflows as agent tools (chat/manual/schedule/form/executeWorkflow triggers)
- `ChatIntegrationService` — Slack Chat SDK lifecycle, webhook routing
- `AgentChatBridge` — Chat SDK mentions → agent execution, rich interactions
- `ComponentMapper` — tool-call suspensions → Slack Block Kit
- `N8NCheckpointStorage` — conversation state persistence
- `generate-agent-code.ts` — schema ↔ code round-trip
### SDK (`packages/@n8n/agents/`)
- `Agent.fromSchema()` — reconstruct agent from persisted schema
- Schema introspection (describe agent → `AgentSchema`)
- Provider capabilities / model catalog
- Credential provider interface
### Frontend (`packages/frontend/editor-ui/src/features/agents/`)
**Routes:** `/home/agents`, `/projects/:projectId/agents`, `/projects/:projectId/agents/:agentId`
**Views:**
- `AgentsListView` — list with sort/create/delete
- `AgentBuilderView` — tabbed builder (code, overview, tools, prompts, memory, evals, integrations) + chat panel
**Components:**
- `AgentChatPanel` — SSE streaming chat with tool call display
- `AgentCodeEditor` — Monaco TypeScript editor
- `AgentOverviewPanel` — model/credential/instructions/description
- `AgentToolsPanel` — add/remove/configure tools + workflow tools
- `AgentPromptsPanel` — system prompt editing
- `AgentMemoryPanel` — memory config (last messages, semantic recall, working memory)
- `AgentEvalsPanel` — evaluation setup
- `AgentIntegrationsPanel` — Slack connect/disconnect
- `AgentSidebar` — tab navigation
- `AgentListItem`, `AgentMiniEditor`
**API composables:** `useAgentApi`, `useAgentSchema`, `useModelCatalog`
### Other
- InstanceAI: `BuilderCard` for agent creation in InstanceAI chat
- `ProjectHeader` modified for agents tab
- Slack OAuth2 credential updates
- `@n8n/config` agents config
- `@n8n/decorators` route options for SSE/webhooks
---
## P0 Gap Analysis — What's Left
### Must Build
| Area | Tickets | Details |
|------|---------|---------|
| **Creation flow UI** | AGENT-1, AGENT-16 | "New agent" landing page, sidebar nav with agents section, agent home screen (chat-first) |
| **Nodes as tools** | AGENT-5 | Run n8n nodes as agent tools via @n8n/workflow-sdk |
| **Chat integrations + triggers** | AGENT-7 | n8n Chat trigger, Schedule trigger (Slack already done) |
| **Default memory** | AGENT-8 | Full N8nMemory for DB-backed memory (checkpoint storage exists but not full memory) |
| **Execution logging** | AGENT-9 | Agent execution history, similar to workflow executions |
| **LLM providers** | AGENT-10 | Expand provider support beyond current catalog |
| **Agent node** | AGENT-13 | "Custom Agent" workflow node: agent dropdown + prompt |
| **"create-tool" tool** | AGENT-14 | Search tool registry, create tools on the fly |
| **Starter templates** | AGENT-15 | Hardcoded agent templates for onboarding |
| **InstanceAI integration** | AGENT-2 | Full InstanceAI subagent for agent creation |
### Lower Priority P0
| Area | Tickets | Details |
|------|---------|---------|
| **Memory integrations** | AGENT-11 | Postgres, Redis, etc. memory backends |
| **Evals** | AGENT-12 | Agent evaluation framework |
| **Memory footprint** | AGENT-17 | Performance analysis (parked) |
### In Review (nearly done)
| Tickets | Details |
|---------|---------|
| AGENT-3 | Agent <-> JSON conversion (schema introspection, fromSchema, code gen) |
| AGENT-4 | Sandbox schema generation (AgentSecureRuntime) |
| AGENT-6 | Workflows as tools (WorkflowToolFactory) |
### Already Solid (no major changes needed)
- Backend CRUD + streaming API
- Agent SDK (schema introspection, fromSchema, code generation)
- Slack integration plumbing (webhooks, Chat SDK bridge, checkpoint storage)
- Workflow-as-tool resolution
- AI builder (streaming code generation)
- Chat execution with tool calls

View File

@ -160,6 +160,7 @@ import IconLucideMaximize from '~icons/lucide/maximize';
import IconLucideMaximize2 from '~icons/lucide/maximize-2';
import IconLucideMenu from '~icons/lucide/menu';
import IconLucideMessageCircle from '~icons/lucide/message-circle';
import IconLucideMessageCirclePlus from '~icons/lucide/message-circle-plus';
import IconLucideMessageSquare from '~icons/lucide/message-square';
import IconLucideMessageSquarePlus from '~icons/lucide/message-square-plus';
import IconLucideMessagesSquare from '~icons/lucide/messages-square';
@ -628,6 +629,7 @@ export const updatedIconSet = {
'message-circle': IconLucideMessageCircle,
'message-square': IconLucideMessageSquare,
'message-square-plus': IconLucideMessageSquarePlus,
'message-circle-plus': IconLucideMessageCirclePlus,
'messages-square': IconLucideMessagesSquare,
mic: IconLucideMic,
milestone: IconLucideMilestone,

View File

@ -40,8 +40,12 @@ const emojiRanges = [
type Props = {
buttonTooltip: string;
buttonSize?: 'small' | 'large';
buttonSize?: 'small' | 'large' | 'xlarge';
isReadOnly?: boolean;
/** Additional CSS class(es) for the outer container element */
containerClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>;
/** Additional CSS class(es) for the trigger button */
buttonClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>;
};
const { t } = useI18n();
@ -166,11 +170,14 @@ async function loadEmojiMetadataMap() {
<template>
<div
ref="container"
:class="{
[$style.container]: true,
[$style.isReadOnly]: isReadOnly,
[$style[props.buttonSize]]: true,
}"
:class="[
{
[$style.container]: true,
[$style.isReadOnly]: isReadOnly,
[$style[props.buttonSize]]: true,
},
containerClass,
]"
:aria-expanded="popupVisible"
role="button"
aria-haspopup="true"
@ -182,7 +189,7 @@ async function loadEmojiMetadataMap() {
</template>
<N8nIconButton
v-if="model.type === 'icon'"
:class="$style['icon-button']"
:class="[$style['icon-button'], buttonClass]"
:icon="model.value"
:size="buttonSize"
icon-only
@ -194,7 +201,7 @@ async function loadEmojiMetadataMap() {
/>
<N8nButton
v-else-if="model.type === 'emoji'"
:class="$style['emoji-button']"
:class="[$style['emoji-button'], buttonClass]"
:size="buttonSize"
icon-only
variant="subtle"
@ -299,6 +306,16 @@ async function loadEmojiMetadataMap() {
width: 18px;
height: 18px;
}
.xlarge & {
width: 24px;
height: 24px;
}
.xxlarge & {
width: 32px;
height: 32px;
}
}
.emoji-button {
@ -308,6 +325,14 @@ async function loadEmojiMetadataMap() {
.small & {
font-size: 18px;
}
.xlarge & {
font-size: 32px;
}
.xxlarge & {
font-size: 40px;
}
}
.popup {

View File

@ -1,4 +1,5 @@
export { default as N8nActionBox } from './N8nActionBox';
export { default as BetaTag } from './BetaTag/BetaTag.vue';
export { default as N8nAskAssistantButton } from './AskAssistantButton';
export {
default as N8nAskAssistantChat,

View File

@ -5424,6 +5424,45 @@
"instanceAi.filesystem.category.shell": "Shell",
"instanceAi.settings.permissions.activateWorkflow.label": "Activate workflow without approval",
"instanceAi.tools.patch-workflow": "Updating workflow",
"agents.sidebar.label": "Agents",
"agents.sidebar.coachmark": "Create agents that you can chat to, run autonomously, or include in workflows",
"agents.sidebar.coachmark.dismiss": "Got it",
"agents.sidebar.newAgent": "New agent",
"agents.newAgent.title": "New agent",
"agents.newAgent.createBlank": "Create blank",
"agents.newAgent.heading": "Let's build something{name}",
"agents.newAgent.placeholder": "Describe what you're working on...",
"agents.newAgent.suggestions": "Suggestions",
"agents.home.iconPicker.tooltip": "Change agent icon",
"agents.home.untitledAgent": "Untitled Agent",
"agents.home.addDescription": "Add a description here",
"agents.home.sendMessage": "Send a message...",
"agents.home.recent": "Recent",
"agents.home.emptyState": "No conversations yet — send a message to get started.",
"agents.settings.title": "Settings",
"agents.settings.cancel": "Cancel",
"agents.settings.save": "Save",
"agents.settings.unsavedChanges": "Unsaved changes",
"agents.settings.triggers": "Triggers",
"agents.settings.triggers.placeholder": "Trigger configuration will be available soon.",
"agents.settings.tools": "Tools",
"agents.settings.advanced": "Advanced",
"agents.settings.code": "Code",
"agents.settings.model": "Model",
"agents.settings.model.selectModel": "Select model...",
"agents.settings.model.selectProvider": "Provider...",
"agents.settings.model.selectCredential": "Select credential...",
"agents.settings.instructions": "Instructions",
"agents.settings.instructions.placeholder": "Enter system instructions for this agent...",
"agents.settings.model.setupCredential": "Set up credentials",
"agents.settings.model.credentialRequired": "Set up credentials to select a model",
"agents.builder.untitledAgent": "Untitled Agent",
"agents.builder.saving": "Saving…",
"agents.builder.save": "Save",
"agents.builder.deleteAgent": "Delete agent",
"agents.builder.deleteConfirmMessage": "Are you sure you want to delete \"{name}\"?",
"agents.builder.deleteConfirmButton": "Delete",
"agents.builder.deleteCancelButton": "Cancel",
"instanceAi.welcomeModal.badge": "Early preview",
"instanceAi.welcomeModal.title": "Try AI Assistant",
"instanceAi.welcomeModal.description": "An agent that can modify workflows, credentials and<br>connect services on your behalf",

View File

@ -0,0 +1,119 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ params: { projectId: 'test-project', agentId: 'test-agent' }, query: {} }),
RouterLink: { template: '<a><slot/></a>' },
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => {
const map: Record<string, string> = {
'agents.home.untitledAgent': 'Untitled Agent',
'agents.home.addDescription': 'Add a description here',
'agents.home.sendMessage': 'Send a message...',
'agents.home.recent': 'Recent',
'agents.home.emptyState': 'No conversations yet',
'agents.home.iconPicker.tooltip': 'Change agent icon',
};
return map[key] ?? key;
},
}),
}));
vi.mock('is-emoji-supported', () => ({
isEmojiSupported: () => true,
}));
describe('AgentHomeContent', () => {
async function renderComponent(props: Partial<Record<string, unknown>> = {}) {
const { default: AgentHomeContent } = await import('../components/AgentHomeContent.vue');
return mount(AgentHomeContent, {
props: {
agentName: 'Test Agent',
agentDescription: 'A test agent description',
agentIcon: { type: 'icon', value: 'robot' },
projectId: 'test-project',
agentId: 'test-agent',
...props,
},
global: {
stubs: {
ChatInputBase: { template: '<div data-testid="chat-input-stub" />' },
N8nIconPicker: { template: '<div data-testid="icon-picker-stub" />' },
N8nText: {
template: '<span v-bind="$attrs"><slot/></span>',
props: ['size', 'color', 'tag', 'bold'],
},
},
},
});
}
it('renders the agent name', async () => {
const wrapper = await renderComponent({ agentName: 'Test Agent' });
const heading = wrapper.find('h2');
expect(heading.text()).toBe('Test Agent');
}, 15_000);
it('renders "Untitled Agent" when name is empty', async () => {
const wrapper = await renderComponent({ agentName: '' });
expect(wrapper.text()).toContain('Untitled Agent');
});
it('renders the agent description', async () => {
const wrapper = await renderComponent();
expect(wrapper.text()).toContain('A test agent description');
});
it('renders placeholder when description is null', async () => {
const wrapper = await renderComponent({ agentDescription: null });
expect(wrapper.text()).toContain('Add a description here');
});
it('enters name editing mode on click', async () => {
const wrapper = await renderComponent();
const heading = wrapper.find('h2');
await heading.trigger('click');
const input = wrapper.find('input');
expect(input.exists()).toBe(true);
expect((input.element as HTMLInputElement).value).toBe('Test Agent');
});
it('emits update:name on Enter', async () => {
const wrapper = await renderComponent();
await wrapper.find('h2').trigger('click');
const input = wrapper.find('input');
await input.setValue('New Name');
await input.trigger('keydown', { key: 'Enter' });
expect(wrapper.emitted('update:name')?.[0]).toEqual(['New Name']);
});
it('cancels name editing on Escape', async () => {
const wrapper = await renderComponent();
await wrapper.find('h2').trigger('click');
const input = wrapper.find('input');
await input.setValue('New Name');
await input.trigger('keydown', { key: 'Escape' });
expect(wrapper.emitted('update:name')).toBeUndefined();
expect(wrapper.find('h2').exists()).toBe(true);
});
it('renders the recent sessions empty state', async () => {
const wrapper = await renderComponent();
expect(wrapper.text()).toContain('Recent');
expect(wrapper.text()).toContain('No conversations yet');
});
it('renders the chat input', async () => {
const wrapper = await renderComponent();
expect(wrapper.find('[data-testid="chat-input-stub"]').exists()).toBe(true);
});
it('renders the icon picker', async () => {
const wrapper = await renderComponent();
expect(wrapper.find('[data-testid="icon-picker-stub"]').exists()).toBe(true);
});
});

View File

@ -0,0 +1,218 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { CHATHUB_TO_CATALOG, CATALOG_TO_CHATHUB } from '../provider-mapping';
/**
* Tests the shared provider mapping used by AgentSettingsSidebar
* to translate between ChatHub provider IDs and Agent SDK catalog IDs.
*/
describe('Provider ID mapping', () => {
it('maps all ChatHub providers to catalog IDs', () => {
expect(CHATHUB_TO_CATALOG.openai).toBe('openai');
expect(CHATHUB_TO_CATALOG.anthropic).toBe('anthropic');
expect(CHATHUB_TO_CATALOG.xAiGrok).toBe('xai');
expect(CHATHUB_TO_CATALOG.deepSeek).toBe('deepseek');
expect(CHATHUB_TO_CATALOG.mistralCloud).toBe('mistral');
expect(CHATHUB_TO_CATALOG.openRouter).toBe('openrouter');
expect(CHATHUB_TO_CATALOG.awsBedrock).toBe('aws-bedrock');
});
it('reverse maps catalog IDs back to ChatHub IDs', () => {
expect(CATALOG_TO_CHATHUB.openai).toBe('openai');
expect(CATALOG_TO_CHATHUB.anthropic).toBe('anthropic');
expect(CATALOG_TO_CHATHUB.xai).toBe('xAiGrok');
expect(CATALOG_TO_CHATHUB.deepseek).toBe('deepSeek');
expect(CATALOG_TO_CHATHUB.mistral).toBe('mistralCloud');
expect(CATALOG_TO_CHATHUB.openrouter).toBe('openRouter');
expect(CATALOG_TO_CHATHUB['aws-bedrock']).toBe('awsBedrock');
});
it('prefers azureOpenAi over azureEntraId for azure-openai reverse mapping', () => {
expect(CATALOG_TO_CHATHUB['azure-openai']).toBe('azureOpenAi');
});
it('has a reverse mapping for every unique catalog ID', () => {
const uniqueCatalogIds = new Set(Object.values(CHATHUB_TO_CATALOG));
for (const catalogId of uniqueCatalogIds) {
expect(CATALOG_TO_CHATHUB[catalogId]).toBeDefined();
}
});
});
// --- Component tests ---
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ params: { projectId: 'test-project', agentId: 'test-agent' } }),
RouterLink: { template: '<a><slot/></a>' },
}));
const baseTextFn = (key: string) => {
const map: Record<string, string> = {
'agents.settings.title': 'Settings',
'agents.settings.cancel': 'Cancel',
'agents.settings.save': 'Save',
'agents.settings.unsavedChanges': 'Unsaved changes',
'agents.settings.model': 'Model',
'agents.settings.instructions': 'Instructions',
'agents.settings.instructions.placeholder': 'Enter instructions...',
'agents.settings.triggers': 'Triggers',
'agents.settings.triggers.placeholder': 'No triggers configured',
'agents.settings.tools': 'Tools',
'agents.settings.advanced': 'Advanced',
'agents.settings.code': 'Code',
};
return map[key] ?? key;
};
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: baseTextFn }),
i18n: { baseText: baseTextFn },
}));
vi.mock('@/features/settings/users/users.store', () => ({
useUsersStore: () => ({ currentUserId: 'user-1' }),
}));
vi.mock('@/features/ai/chatHub/chat.store', () => ({
useChatStore: () => ({
agents: {},
fetchAgents: vi.fn(),
}),
}));
vi.mock('@/features/ai/chatHub/composables/useChatCredentials', () => ({
useChatCredentials: () => ({
credentialsByProvider: { value: null },
selectCredential: vi.fn(),
}),
}));
vi.mock('@/features/ai/chatHub/chat.utils', () => ({
isLlmProviderModel: () => true,
}));
const mockConfig = {
name: 'Test Agent',
model: 'anthropic/claude-sonnet-4-6',
credential: 'cred-1',
instructions: 'Be helpful',
};
describe('AgentSettingsSidebar', () => {
async function renderComponent(props: Record<string, unknown> = {}) {
const { default: AgentSettingsSidebar } = await import(
'../components/AgentSettingsSidebar.vue'
);
return mount(AgentSettingsSidebar, {
props: {
config: mockConfig,
agentTools: {},
updatedAt: '2026-04-09T00:00:00Z',
isDirty: false,
...props,
},
global: {
stubs: {
ModelSelector: { template: '<div data-testid="model-selector-stub" />' },
AgentToolsPanel: { template: '<div data-testid="tools-panel-stub" />' },
AgentMemoryPanel: { template: '<div data-testid="memory-panel-stub" />' },
AgentCodeEditor: { template: '<div data-testid="code-editor-stub" />' },
N8nButton: {
template:
'<button :disabled="disabled" @click="$emit(\'click\')"><slot/>{{ label }}</button>',
props: ['type', 'size', 'label', 'disabled'],
emits: ['click'],
},
N8nCallout: { template: '<div data-testid="callout-stub"><slot/></div>' },
N8nIcon: { template: '<span />', props: ['icon', 'size'] },
N8nInput: {
template: '<textarea data-testid="instructions-input" />',
props: ['modelValue', 'type', 'rows', 'placeholder'],
},
N8nText: {
template: '<span v-bind="$attrs"><slot/></span>',
props: ['tag', 'bold', 'size', 'color'],
},
},
},
});
}
it('renders the settings header', async () => {
const wrapper = await renderComponent();
expect(wrapper.text()).toContain('Settings');
}, 15_000);
it('renders all sections (Model, Instructions, Triggers, Tools, Advanced, Code)', async () => {
const wrapper = await renderComponent();
expect(wrapper.text()).toContain('Model');
expect(wrapper.text()).toContain('Instructions');
expect(wrapper.text()).toContain('Triggers');
expect(wrapper.text()).toContain('Tools');
expect(wrapper.text()).toContain('Advanced');
expect(wrapper.text()).toContain('Code');
});
it('shows Save and Cancel buttons', async () => {
const wrapper = await renderComponent();
expect(wrapper.text()).toContain('Save');
expect(wrapper.text()).toContain('Cancel');
});
it('disables Save/Cancel when not dirty', async () => {
const wrapper = await renderComponent({ isDirty: false });
const buttons = wrapper.findAll('button');
const cancelBtn = buttons.find((b) => b.text().includes('Cancel'));
expect(cancelBtn?.attributes('disabled')).toBeDefined();
});
it('shows unsaved changes banner when dirty', async () => {
const wrapper = await renderComponent({ isDirty: true });
expect(wrapper.text()).toContain('Unsaved changes');
});
it('hides unsaved changes banner when not dirty', async () => {
const wrapper = await renderComponent({ isDirty: false });
expect(wrapper.text()).not.toContain('Unsaved changes');
});
it('emits save when Save button is clicked', async () => {
const wrapper = await renderComponent({ isDirty: true });
const buttons = wrapper.findAll('button');
const saveBtn = buttons.find((b) => b.text().includes('Save'));
await saveBtn?.trigger('click');
expect(wrapper.emitted('save')).toBeTruthy();
});
it('emits cancel when Cancel button is clicked', async () => {
const wrapper = await renderComponent({ isDirty: true });
const buttons = wrapper.findAll('button');
const cancelBtn = buttons.find((b) => b.text().includes('Cancel'));
await cancelBtn?.trigger('click');
expect(wrapper.emitted('cancel')).toBeTruthy();
});
it('renders the model selector', async () => {
const wrapper = await renderComponent();
expect(wrapper.find('[data-testid="model-selector-stub"]').exists()).toBe(true);
});
it('expands Tools section when clicked', async () => {
const wrapper = await renderComponent();
expect(wrapper.find('[data-testid="tools-panel-stub"]').exists()).toBe(false);
const toolsHeader = wrapper.findAll('button').find((b) => b.text().includes('Tools'));
await toolsHeader?.trigger('click');
expect(wrapper.find('[data-testid="tools-panel-stub"]').exists()).toBe(true);
});
it('expands Code section when clicked', async () => {
const wrapper = await renderComponent();
expect(wrapper.find('[data-testid="code-editor-stub"]').exists()).toBe(false);
const codeHeader = wrapper.findAll('button').find((b) => b.text().includes('Code'));
await codeHeader?.trigger('click');
expect(wrapper.find('[data-testid="code-editor-stub"]').exists()).toBe(true);
});
});

View File

@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ params: { projectId: 'test-project' }, query: {} }),
RouterLink: { template: '<a><slot/></a>' },
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }),
}));
vi.mock('@/features/settings/users/users.store', () => ({
useUsersStore: () => ({ currentUser: { firstName: 'Test' } }),
}));
vi.mock('@/features/collaboration/projects/projects.store', () => ({
useProjectsStore: () => ({ personalProject: { id: 'test-project' } }),
}));
vi.mock('../composables/useAgentApi', () => ({
createAgent: vi.fn().mockResolvedValue({ id: 'new-agent-id', name: 'New Agent' }),
}));
vi.mock('@/app/composables/useTelemetry', () => ({
useTelemetry: () => ({ track: vi.fn() }),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string, opts?: { interpolate?: Record<string, string> }) => {
if (key === 'agents.newAgent.heading') {
return `Let's build something${opts?.interpolate?.name ?? ''}`;
}
return key;
},
}),
}));
describe('NewAgentView', () => {
async function renderView() {
const { default: NewAgentView } = await import('../views/NewAgentView.vue');
return mount(NewAgentView, {
global: {
stubs: {
ChatInputBase: { template: '<div data-testid="chat-input-stub" />' },
N8nButton: { template: '<button v-bind="$attrs"><slot/></button>' },
N8nText: { template: '<span v-bind="$attrs"><slot/></span>' },
},
},
});
}
it('renders the heading with user name', async () => {
const wrapper = await renderView();
expect(wrapper.text()).toContain("Let's build something, Test");
}, 15_000);
it('renders 3 suggestion cards', async () => {
const wrapper = await renderView();
const cards = wrapper.findAll('[data-testid="agent-suggestion-card"]');
expect(cards).toHaveLength(3);
});
it('renders Create blank button', async () => {
const wrapper = await renderView();
const btn = wrapper.find('[data-testid="create-blank-agent"]');
expect(btn.exists()).toBe(true);
});
it('populates input when clicking a suggestion', async () => {
const wrapper = await renderView();
const cards = wrapper.findAll('[data-testid="agent-suggestion-card"]');
await cards[0].trigger('click');
// The component should have set inputText — verify via the component's internal state
expect((wrapper.vm as unknown as { inputText: string }).inputText).toContain('SEO');
});
});

View File

@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { AGENTS_LIST_VIEW, AGENT_BUILDER_VIEW, PROJECT_AGENTS, NEW_AGENT_VIEW } from '../constants';
describe('Agent constants', () => {
it('exports all required route names', () => {
expect(AGENTS_LIST_VIEW).toBe('AgentsListView');
expect(AGENT_BUILDER_VIEW).toBe('AgentBuilderView');
expect(PROJECT_AGENTS).toBe('ProjectAgents');
expect(NEW_AGENT_VIEW).toBe('NewAgentView');
});
});

View File

@ -0,0 +1,8 @@
import { createEventBus } from '@n8n/utils/event-bus';
export interface AgentsEventBusEvents {
/** Fired when an agent's name or metadata is updated */
agentUpdated: never;
}
export const agentsEventBus = createEventBus<AgentsEventBusEvents>();

View File

@ -1,20 +1,33 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { N8nIcon, N8nMarkdown, N8nPromptInput } from '@n8n/design-system';
import { ref, reactive, computed, onMounted, nextTick, useTemplateRef } from 'vue';
import { N8nIcon, N8nIconButton, N8nInput, N8nText } from '@n8n/design-system';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useI18n } from '@n8n/i18n';
import { getBuilderMessages, clearBuilderMessages } from '../composables/useAgentApi';
import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChunk.vue';
import ChatTypingIndicator from '@/features/ai/chatHub/components/ChatTypingIndicator.vue';
const props = defineProps<{
visible: boolean;
projectId: string;
agentId: string;
}>();
const props = withDefaults(
defineProps<{
visible?: boolean;
projectId: string;
agentId: string;
mode?: 'panel' | 'inline';
initialMessage?: string;
}>(),
{
visible: true,
mode: 'panel',
},
);
const emit = defineEmits<{
close: [];
codeUpdated: [];
codeDelta: [delta: string];
configUpdated: [];
}>();
const locale = useI18n();
const rootStore = useRootStore();
interface ChatMessage {
@ -23,19 +36,39 @@ interface ChatMessage {
content: string;
thinking?: string;
toolCalls?: Array<{ tool: string; input?: unknown; output?: unknown }>;
status?: 'streaming' | 'success' | 'error';
}
const activeTab = ref<'test' | 'builder'>('test');
const testMessages = ref<ChatMessage[]>([]);
const activeTab = ref<'test' | 'builder'>(props.mode === 'inline' ? 'builder' : 'test');
const messages = ref<ChatMessage[]>([]);
const builderMessages = ref<ChatMessage[]>([]);
const inputText = ref('');
const isStreaming = ref(false);
const abortController = ref<AbortController | null>(null);
const builderHistoryLoaded = ref(false);
const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef');
const inputRef = useTemplateRef<InstanceType<typeof N8nInput>>('inputRef');
const messages = computed(() =>
activeTab.value === 'test' ? testMessages.value : builderMessages.value,
const currentMessages = computed(() =>
activeTab.value === 'builder' ? builderMessages.value : messages.value,
);
const messagingState = computed<'idle' | 'waitingFirstChunk' | 'receiving'>(() => {
if (!isStreaming.value) return 'idle';
const msgs = currentMessages.value;
const lastMsg = msgs[msgs.length - 1];
if (!lastMsg || lastMsg.role === 'user') return 'waitingFirstChunk';
return 'receiving';
});
function scrollToBottom() {
void nextTick(() => {
if (scrollRef.value) {
scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
}
});
}
/** Convert persisted agent messages into the frontend ChatMessage format. */
function convertDbMessages(dbMessages: unknown[]): ChatMessage[] {
const result: ChatMessage[] = [];
@ -52,7 +85,6 @@ function convertDbMessages(dbMessages: unknown[]): ChatMessage[] {
}>;
};
if (!msg.role || !Array.isArray(msg.content)) continue;
// Only show user and assistant messages
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
let text = '';
@ -120,41 +152,65 @@ function onSubmit() {
void sendMessage();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}
async function sendMessage() {
const text = inputText.value.trim();
if (!text || isStreaming.value) return;
const targetMessages = activeTab.value === 'test' ? testMessages : builderMessages;
const targetMessages = activeTab.value === 'builder' ? builderMessages : messages;
targetMessages.value.push({
id: crypto.randomUUID(),
role: 'user',
content: text,
status: 'success',
});
inputText.value = '';
scrollToBottom();
if (activeTab.value === 'builder') {
await streamFromEndpoint('build', text, builderMessages);
} else {
await streamFromEndpoint('chat', text, testMessages);
}
const endpoint = activeTab.value === 'builder' ? 'build' : 'chat';
await streamFromEndpoint(endpoint, text);
}
async function streamFromEndpoint(
endpoint: 'build' | 'chat',
message: string,
targetMessages: typeof testMessages,
) {
function sendMessageFromOutside(message: string) {
inputText.value = message;
void sendMessage();
}
function stopGenerating() {
abortController.value?.abort();
}
defineExpose({ sendMessageFromOutside });
onMounted(() => {
if (props.initialMessage) {
sendMessageFromOutside(props.initialMessage);
}
});
async function streamFromEndpoint(endpoint: 'build' | 'chat', message: string) {
isStreaming.value = true;
let builderMutated = false;
const targetMessages = endpoint === 'build' ? builderMessages : messages;
const assistantMsg = reactive<ChatMessage>({
id: crypto.randomUUID(),
role: 'assistant',
content: '',
thinking: '',
toolCalls: [],
status: 'streaming',
});
let msgAdded = false;
const controller = new AbortController();
abortController.value = controller;
try {
const { baseUrl } = rootStore.restApiContext;
const browserId = localStorage.getItem('n8n-browserId') ?? '';
@ -167,10 +223,12 @@ async function streamFromEndpoint(
},
credentials: 'include',
body: JSON.stringify({ message }),
signal: controller.signal,
});
if (!response.ok || !response.body) {
assistantMsg.content = `Error: ${response.statusText || 'Failed to reach agent'}`;
assistantMsg.status = 'error';
targetMessages.value.push(assistantMsg);
return;
}
@ -204,12 +262,14 @@ async function streamFromEndpoint(
if (!msgAdded) {
targetMessages.value.push(assistantMsg);
msgAdded = true;
scrollToBottom();
}
};
if (typeof data.text === 'string') {
ensureMsg();
assistantMsg.content += data.text;
scrollToBottom();
}
if (typeof data.thinking === 'string') {
@ -217,6 +277,14 @@ async function streamFromEndpoint(
assistantMsg.thinking = (assistantMsg.thinking ?? '') + data.thinking;
}
if (typeof data.codeDelta === 'string') {
emit('codeDelta', data.codeDelta);
}
if (typeof data.code === 'string') {
emit('codeUpdated');
}
if (data.configUpdated !== undefined || data.toolUpdated !== undefined) {
builderMutated = true;
emit('configUpdated');
@ -248,16 +316,26 @@ async function streamFromEndpoint(
if (typeof data.error === 'string') {
ensureMsg();
assistantMsg.content += `\n\nError: ${data.error}`;
assistantMsg.status = 'error';
}
}
}
assistantMsg.status = 'success';
} catch (e) {
if (!msgAdded) {
targetMessages.value.push(assistantMsg);
if (e instanceof DOMException && e.name === 'AbortError') {
assistantMsg.status = 'success';
} else {
if (!msgAdded) {
targetMessages.value.push(assistantMsg);
}
assistantMsg.content = `Error: ${e instanceof Error ? e.message : 'Unknown error'}`;
assistantMsg.status = 'error';
}
assistantMsg.content = `Error: ${e instanceof Error ? e.message : 'Unknown error'}`;
} finally {
abortController.value = null;
isStreaming.value = false;
scrollToBottom();
// Emit a final refresh after the builder stream completes to ensure the
// latest config is shown even if no individual tool events fired it.
if (endpoint === 'build' && builderMutated) {
@ -268,8 +346,9 @@ async function streamFromEndpoint(
</script>
<template>
<aside v-if="visible" :class="$style.panel">
<div :class="$style.header">
<aside v-if="visible" :class="[mode === 'inline' ? $style.inlinePanel : $style.panel]">
<!-- Tabs header (only shown in panel mode) -->
<div v-if="mode === 'panel'" :class="$style.header">
<div :class="$style.tabs">
<button
:class="[$style.tabBtn, activeTab === 'test' && $style.tabBtnActive]"
@ -296,97 +375,157 @@ async function streamFromEndpoint(
>
<N8nIcon icon="trash-2" :size="14" />
</button>
<button :class="$style.closeBtn" data-testid="chat-close-button" @click="emit('close')">
<N8nIcon icon="x" :size="16" />
</button>
</div>
</div>
<div :class="$style.messages">
<div v-if="messages.length === 0 && !isStreaming" :class="$style.emptyState">
<!-- Messages -->
<div ref="scrollRef" :class="$style.messages">
<div v-if="currentMessages.length === 0 && !isStreaming" :class="$style.emptyState">
<N8nIcon icon="message-square" :size="32" />
<p :class="$style.emptyTitle">
{{ activeTab === 'test' ? 'Test your agent' : 'Build with AI' }}
</p>
<p :class="$style.emptySubtitle">
{{
activeTab === 'test'
? 'Send a message to test your agent'
: 'Describe what you want and the builder will write the code'
}}
</p>
<N8nText tag="p" bold>Test your agent</N8nText>
<N8nText size="small" color="text-light"> Send a message to start a conversation </N8nText>
</div>
<div v-else :class="$style.messageList">
<template v-else>
<div
v-for="msg in messages"
v-for="msg in currentMessages"
:key="msg.id"
:class="[
$style.message,
msg.role === 'user' ? $style.messageUser : $style.messageAssistant,
]"
:class="[$style.message, msg.role === 'user' ? $style.user : $style.assistant]"
>
<!-- Thinking block -->
<details v-if="msg.thinking" :class="$style.thinkingBlock">
<summary :class="$style.thinkingSummary">Thinking...</summary>
<pre :class="$style.thinkingContent">{{ msg.thinking }}</pre>
</details>
<!-- Tool calls -->
<div v-if="msg.toolCalls?.length" :class="$style.toolCalls">
<div v-for="(tc, i) in msg.toolCalls" :key="i" :class="$style.toolCall">
<span :class="$style.toolName">{{ tc.tool }}</span>
<span v-if="tc.output !== undefined" :class="$style.toolStatus"></span>
<span v-else :class="$style.toolStatusPending"></span>
</div>
<!-- Avatar -->
<div :class="$style.avatar">
<N8nIcon v-if="msg.role === 'user'" icon="user" width="20" height="20" />
<N8nIcon v-else icon="robot" width="20" height="20" />
</div>
<!-- Message content -->
<N8nMarkdown
v-if="msg.role === 'assistant' && msg.content"
:content="msg.content"
:class="$style.markdownContent"
/>
<div v-else-if="msg.role === 'user'" :class="$style.userContent">{{ msg.content }}</div>
<!-- Content -->
<div :class="$style.content">
<!-- Thinking -->
<details v-if="msg.thinking" :class="$style.thinkingBlock">
<summary :class="$style.thinkingSummary">
<N8nIcon icon="brain" :size="12" />
Thinking...
</summary>
<div :class="$style.thinkingContent">{{ msg.thinking }}</div>
</details>
<!-- Tool calls -->
<div v-if="msg.toolCalls?.length" :class="$style.toolCalls">
<div v-for="(tc, i) in msg.toolCalls" :key="i" :class="$style.toolCall">
<N8nIcon icon="wrench" :size="12" />
<span :class="$style.toolName">{{ tc.tool }}</span>
<N8nIcon
v-if="tc.output !== undefined"
icon="check"
:size="12"
:class="$style.toolDone"
/>
<ChatTypingIndicator v-else />
</div>
</div>
<!-- User message -->
<div v-if="msg.role === 'user'" :class="[$style.chatMessage, $style.chatMessageUser]">
{{ msg.content }}
</div>
<!-- Assistant message -->
<div
v-else-if="msg.content"
:class="[$style.chatMessage, { [$style.chatMessageError]: msg.status === 'error' }]"
>
<div :class="$style.markdownContent">
<ChatMarkdownChunk
:source="{ type: 'text', content: msg.content }"
@open-artifact="() => {}"
/>
</div>
</div>
<!-- Typing indicator for assistant still streaming with no content -->
<ChatTypingIndicator
v-if="
msg.role === 'assistant' &&
msg.status === 'streaming' &&
!msg.content &&
!msg.toolCalls?.length
"
:class="$style.typingIndicator"
/>
</div>
</div>
<!-- Loading indicator -->
<div
v-if="
isStreaming && messages.length > 0 && messages[messages.length - 1]?.role === 'user'
"
:class="$style.loadingIndicator"
>
<span :class="$style.dot" />
<span :class="[$style.dot, $style.dot2]" />
<span :class="[$style.dot, $style.dot3]" />
<!-- Waiting for first chunk indicator -->
<div v-if="messagingState === 'waitingFirstChunk'" :class="$style.message">
<div :class="$style.avatar">
<N8nIcon icon="robot" width="20" height="20" />
</div>
<div :class="$style.content">
<ChatTypingIndicator :class="$style.typingIndicator" />
</div>
</div>
</div>
</template>
</div>
<!-- Input -->
<div :class="$style.inputArea">
<N8nPromptInput
v-model="inputText"
:placeholder="activeTab === 'builder' ? 'Ask the builder...' : 'Type a message...'"
:streaming="isStreaming"
:disabled="isStreaming"
refocus-after-send
data-testid="chat-input"
@submit="onSubmit"
/>
<form :class="$style.prompt" @submit.prevent="onSubmit">
<N8nInput
ref="inputRef"
:model-value="inputText"
type="textarea"
placeholder="Type a message..."
autocomplete="off"
:autosize="{ minRows: 1, maxRows: 6 }"
autofocus
:disabled="isStreaming"
data-testid="chat-input"
@update:model-value="inputText = $event"
@keydown="onKeydown"
/>
<div :class="$style.promptFooter">
<div />
<div :class="$style.promptActions">
<N8nIconButton
v-if="messagingState !== 'receiving'"
type="submit"
:disabled="isStreaming || !inputText.trim()"
:title="locale.baseText('chatHub.chat.prompt.button.send')"
:loading="messagingState === 'waitingFirstChunk'"
icon="arrow-up"
icon-size="large"
@click.stop
/>
<N8nIconButton
v-else
native-type="button"
:title="locale.baseText('chatHub.chat.prompt.button.stopGenerating')"
icon="square"
icon-size="large"
@click.stop="stopGenerating"
/>
</div>
</div>
</form>
</div>
</aside>
</template>
<style module>
<style lang="scss" module>
.panel {
width: 400px;
min-width: 400px;
background-color: var(--color--foreground--tint-2);
border-left: var(--border-width) var(--border-style) var(--color--foreground);
display: flex;
flex-direction: column;
}
.inlinePanel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.header {
display: flex;
align-items: center;
@ -448,27 +587,14 @@ async function streamFromEndpoint(
color: var(--color--danger);
}
.closeBtn {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
cursor: pointer;
color: var(--color--text--tint-2);
padding: var(--spacing--4xs);
border-radius: var(--radius);
}
.closeBtn:hover {
background-color: var(--color--foreground--tint-1);
color: var(--color--text);
}
/* Messages area — matches ChatHub layout */
.messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing--sm);
padding: var(--spacing--lg) var(--spacing--xl);
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
}
.emptyState {
@ -481,107 +607,71 @@ async function streamFromEndpoint(
color: var(--color--text--tint-2);
}
.emptyTitle {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
/* Message layout — mirrors ChatMessage.vue styles */
.message {
position: relative;
padding-left: 40px;
}
.avatar {
position: absolute;
left: 0;
top: 0;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color--background);
color: var(--color--text--tint-1);
margin: 0;
}
.emptySubtitle {
font-size: var(--font-size--2xs);
color: var(--color--text--tint-2);
margin: 0;
text-align: center;
}
.messageList {
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
align-items: stretch;
}
.message {
padding: var(--spacing--2xs) var(--spacing--xs);
border-radius: var(--radius--lg);
font-size: var(--font-size--sm);
font-family: var(--font-family);
.chatMessage {
overflow-wrap: break-word;
font-size: var(--font-size--md);
line-height: var(--line-height--xl);
max-width: 90%;
word-break: break-word;
}
.messageUser {
align-self: flex-end;
background-color: var(--color--primary);
color: var(--color--foreground--tint-2);
}
.messageAssistant {
align-self: flex-start;
background-color: var(--color--foreground--tint-1);
color: var(--color--text);
.chatMessageUser {
padding: var(--spacing--2xs) var(--spacing--sm);
border-radius: var(--radius--xl);
background-color: var(--color--background);
white-space: pre-wrap;
width: fit-content;
max-width: 100%;
}
.userContent {
font-size: var(--font-size--sm);
line-height: var(--line-height--xl);
font-family: var(--font-family);
white-space: pre-wrap;
.chatMessageError {
padding: var(--spacing--xs) var(--spacing--sm);
border-radius: var(--radius--lg);
background-color: var(--color--danger--tint-4);
border: var(--border-width) var(--border-style) var(--color--danger--tint-3);
color: var(--color--danger);
}
.markdownContent {
font-size: var(--font-size--sm);
color: var(--color--text--shade-1);
font-size: var(--font-size--md);
line-height: var(--line-height--xl);
font-family: var(--font-family);
}
.markdownContent :global(p) {
margin: 0 0 var(--spacing--3xs);
}
.markdownContent :global(p:last-child) {
margin-bottom: 0;
}
.markdownContent :global(pre) {
background-color: var(--color--foreground);
padding: var(--spacing--3xs) var(--spacing--2xs);
border-radius: var(--radius);
overflow-x: auto;
font-size: var(--font-size--2xs);
margin: var(--spacing--3xs) 0;
}
.markdownContent :global(code) {
font-size: var(--font-size--2xs);
background-color: var(--color--foreground);
padding: 1px var(--spacing--5xs);
border-radius: var(--radius--sm);
}
.markdownContent :global(pre code) {
background: none;
padding: 0;
}
.markdownContent :global(h1),
.markdownContent :global(h2),
.markdownContent :global(h3) {
font-size: var(--font-size--xs);
font-weight: var(--font-weight--bold);
margin: var(--spacing--3xs) 0;
}
.markdownContent :global(ul),
.markdownContent :global(ol) {
padding-left: var(--spacing--sm);
margin: var(--spacing--3xs) 0;
> *:last-child > *:last-child {
margin-bottom: 0;
}
> *:first-child > *:first-child {
margin-top: 0;
}
}
/* Thinking block */
.thinkingBlock {
margin-bottom: var(--spacing--4xs);
margin-bottom: var(--spacing--2xs);
font-size: var(--font-size--2xs);
}
@ -589,6 +679,9 @@ async function streamFromEndpoint(
cursor: pointer;
color: var(--color--text--tint-2);
font-style: italic;
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
.thinkingContent {
@ -601,74 +694,96 @@ async function streamFromEndpoint(
overflow-y: auto;
}
/* Tool calls */
.toolCalls {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: var(--spacing--4xs);
margin-bottom: var(--spacing--4xs);
margin-bottom: var(--spacing--2xs);
}
.toolCall {
display: inline-flex;
align-items: center;
gap: var(--spacing--5xs);
font-size: var(--font-size--3xs);
background-color: var(--color--foreground);
padding: var(--spacing--5xs) var(--spacing--3xs);
border-radius: var(--radius--sm);
gap: var(--spacing--4xs);
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
padding: var(--spacing--4xs) var(--spacing--2xs);
background-color: var(--color--foreground--tint-2);
border-radius: var(--radius);
width: fit-content;
}
.toolName {
color: var(--color--text--tint-1);
font-family: monospace;
}
.toolStatus {
.toolDone {
color: var(--color--success);
}
.toolStatusPending {
color: var(--color--text--tint-2);
}
.loadingIndicator {
display: flex;
gap: var(--spacing--4xs);
padding: var(--spacing--2xs) 0;
align-items: center;
}
.dot {
width: var(--spacing--3xs);
height: var(--spacing--3xs);
border-radius: 50%;
background-color: var(--color--text--tint-2);
animation: dotPulse 1.4s infinite ease-in-out both;
}
.dot2 {
animation-delay: 0.16s;
}
.dot3 {
animation-delay: 0.32s;
}
@keyframes dotPulse {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
.typingIndicator {
margin-top: var(--spacing--xs);
}
/* Input area — matches ChatPromptFull layout */
.inputArea {
padding: var(--spacing--2xs) var(--spacing--sm);
padding: var(--spacing--sm);
border-top: var(--border-width) var(--border-style) var(--color--foreground);
}
.prompt {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
& textarea {
font-size: var(--font-size--md);
line-height: 1.5em;
padding: var(--spacing--sm);
padding-bottom: var(--spacing--3xl);
color: var(--color--text--shade-1);
box-shadow: 0 10px 24px 0 var(--color--black-alpha-100);
border-radius: var(--radius--xl);
&::placeholder {
color: var(--color--text--tint-1);
}
}
:global(.n8n-input__wrapper) {
--input--radius: var(--radius--xl);
}
}
.promptFooter {
position: absolute;
bottom: 1px;
left: 1px;
width: calc(100% - 2px);
z-index: 10;
background: var(--color--background--light-2);
border-radius: var(--radius--xl);
padding: var(--spacing--sm);
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--spacing--sm);
pointer-events: none;
& > * {
pointer-events: auto;
}
}
.promptActions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
& button path {
stroke-width: 2.5;
}
}
</style>

View File

@ -281,7 +281,7 @@ onBeforeUnmount(() => {
.badge {
font-size: var(--font-size--3xs);
color: var(--color--text--tint-2);
color: var(--color--text--tint-1);
background-color: var(--color--foreground--tint-1);
padding: var(--spacing--5xs) var(--spacing--3xs);
border-radius: var(--radius--sm);

View File

@ -0,0 +1,269 @@
<script setup lang="ts">
import { ref } from 'vue';
import { N8nIconPicker, N8nText } from '@n8n/design-system';
import type { IconOrEmoji } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
const locale = useI18n();
const props = defineProps<{
agentName: string;
agentDescription: string | null;
agentIcon: IconOrEmoji;
projectId: string;
agentId: string;
}>();
const emit = defineEmits<{
'send-message': [message: string];
'update:name': [name: string];
'update:description': [description: string];
'update:icon': [icon: IconOrEmoji];
}>();
const inputText = ref('');
const editingName = ref(false);
const editingDescription = ref(false);
const localName = ref(props.agentName);
const localDescription = ref(props.agentDescription ?? '');
function saveName() {
const trimmed = localName.value.trim();
if (trimmed && trimmed !== props.agentName) {
emit('update:name', trimmed);
} else {
localName.value = props.agentName;
}
editingName.value = false;
}
function saveDescription() {
const trimmed = localDescription.value.trim();
if (trimmed !== (props.agentDescription ?? '')) {
emit('update:description', trimmed);
}
editingDescription.value = false;
}
function onNameKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
saveName();
} else if (event.key === 'Escape') {
localName.value = props.agentName;
editingName.value = false;
}
}
function onDescriptionKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
saveDescription();
} else if (event.key === 'Escape') {
localDescription.value = props.agentDescription ?? '';
editingDescription.value = false;
}
}
function submitMessage() {
if (!inputText.value.trim()) return;
emit('send-message', inputText.value.trim());
inputText.value = '';
}
</script>
<template>
<div :class="$style.container">
<div :class="$style.hero">
<div :class="$style.agentIcon">
<N8nIconPicker
:model-value="agentIcon"
button-size="xlarge"
:button-tooltip="locale.baseText('agents.home.iconPicker.tooltip')"
@update:model-value="emit('update:icon', $event)"
/>
</div>
<input
v-if="editingName"
v-model="localName"
:class="$style.nameInput"
autofocus
@blur="saveName"
@keydown="onNameKeydown"
/>
<h2
v-else
:class="$style.agentName"
@click="
localName = agentName;
editingName = true;
"
>
{{ agentName || locale.baseText('agents.home.untitledAgent') }}
</h2>
<textarea
v-if="editingDescription"
v-model="localDescription"
:class="$style.descriptionInput"
:placeholder="locale.baseText('agents.home.addDescription')"
rows="3"
autofocus
@blur="saveDescription"
@keydown="onDescriptionKeydown"
/>
<N8nText
v-else
:class="$style.description"
size="small"
color="text-light"
@click="
localDescription = agentDescription ?? '';
editingDescription = true;
"
>
{{ agentDescription || locale.baseText('agents.home.addDescription') }}
</N8nText>
</div>
<div :class="$style.chatInput">
<ChatInputBase
v-model="inputText"
:placeholder="locale.baseText('agents.home.sendMessage')"
:is-streaming="false"
:can-submit="inputText.trim().length > 0"
:show-voice="true"
:show-attach="true"
@submit="submitMessage"
/>
</div>
<div :class="$style.recent">
<div :class="$style.recentHeader">
<N8nText size="small" color="text-light" bold>{{
locale.baseText('agents.home.recent')
}}</N8nText>
</div>
<div :class="$style.emptyState">
<N8nText size="small" color="text-light">
{{ locale.baseText('agents.home.emptyState') }}
</N8nText>
</div>
</div>
</div>
</template>
<style module lang="scss">
.container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing--2xl) var(--spacing--lg) var(--spacing--lg);
overflow-y: auto;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--4xs);
margin-bottom: var(--spacing--lg);
}
.agentIcon {
display: flex;
align-items: center;
justify-content: center;
color: var(--color--text--tint-1);
margin-bottom: var(--spacing--2xs);
> * > * :global(.button) {
--button--color--background: transparent;
--button--color--background-hover: var(--color--foreground--tint-2);
--button--color--background-active: var(--color--foreground--tint-1);
--button--shadow: none;
--button--shadow--hover: none;
--button--shadow--active: none;
--button--border--shadow: none;
--button--border--shadow--hover: none;
--button--border--shadow--active: none;
}
}
.agentName {
font-size: var(--font-size--xl);
font-weight: var(--font-weight--bold);
color: var(--color--text);
margin: 0;
cursor: pointer;
padding: var(--spacing--4xs) var(--spacing--2xs);
border-radius: var(--radius);
}
.agentName:hover {
background-color: var(--color--foreground--tint-2);
}
.nameInput {
font-size: var(--font-size--xl);
font-weight: var(--font-weight--bold);
color: var(--color--text);
background: none;
border: var(--border-width) var(--border-style) var(--color--primary);
border-radius: var(--radius);
padding: var(--spacing--4xs) var(--spacing--2xs);
outline: none;
text-align: center;
font-family: var(--font-family);
}
.description {
cursor: pointer;
padding: var(--spacing--4xs) var(--spacing--2xs);
border-radius: var(--radius);
}
.description:hover {
background-color: var(--color--foreground--tint-2);
}
.descriptionInput {
font-size: var(--font-size--sm);
color: var(--color--text--tint-1);
background: none;
border: var(--border-width) var(--border-style) var(--color--primary);
border-radius: var(--radius);
padding: var(--spacing--2xs) var(--spacing--xs);
outline: none;
font-family: var(--font-family);
resize: vertical;
min-width: 300px;
line-height: var(--line-height--xl);
}
.chatInput {
width: 100%;
max-width: 600px;
margin-bottom: var(--spacing--xl);
}
.recent {
width: 100%;
max-width: 600px;
}
.recentHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing--xs);
}
.emptyState {
text-align: center;
padding: var(--spacing--lg) 0;
}
</style>

View File

@ -0,0 +1,467 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { N8nButton, N8nCallout, N8nIcon, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { ChatHubConversationModel, ChatHubProvider, ChatModelDto } from '@n8n/api-types';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials';
import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils';
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
import type { AgentJsonConfig } from '../types';
import type { CustomToolEntry } from '../agent.types';
import { CHATHUB_TO_CATALOG, CATALOG_TO_CHATHUB } from '../provider-mapping';
import AgentToolsPanel from './AgentToolsPanel.vue';
import AgentMemoryPanel from './AgentMemoryPanel.vue';
import AgentCodeEditor from './AgentCodeEditor.vue';
const locale = useI18n();
const usersStore = useUsersStore();
const chatStore = useChatStore();
const props = defineProps<{
config: AgentJsonConfig | null;
agentTools: Record<string, CustomToolEntry>;
updatedAt: string;
isDirty: boolean;
}>();
const emit = defineEmits<{
'update:config': [changes: Partial<AgentJsonConfig>];
save: [];
cancel: [];
}>();
// --- Model & credential state (reusing ChatHub infrastructure) ---
const { credentialsByProvider, selectCredential } = useChatCredentials(
usersStore.currentUserId ?? 'anonymous',
);
const instructions = ref(props.config?.instructions ?? '');
// Fetch agents when credentials are ready
watch(
credentialsByProvider,
(credentials) => {
if (credentials) {
void chatStore.fetchAgents(credentials);
}
},
{ immediate: true },
);
/** Parse "provider/model" string into provider and model parts */
function parseModelString(model: string): { provider: string; name: string } | null {
const slashIndex = model.indexOf('/');
if (slashIndex <= 0) return null;
return { provider: model.slice(0, slashIndex), name: model.slice(slashIndex + 1) };
}
// Build a ChatModelDto from the current config so ModelSelector can show it as selected
const selectedAgent = computed<ChatModelDto | null>(() => {
if (!props.config?.model) return null;
const parsed = parseModelString(props.config.model);
if (!parsed) return null;
const chatHubProvider = CATALOG_TO_CHATHUB[parsed.provider];
if (!chatHubProvider) return null;
return {
model: {
provider: chatHubProvider,
model: parsed.name,
} as ChatHubConversationModel,
name: parsed.name,
description: null,
icon: null,
updatedAt: null,
createdAt: null,
metadata: {} as ChatModelDto['metadata'],
groupName: null,
groupIcon: null,
};
});
function onModelChange(selection: ChatHubConversationModel) {
if (!isLlmProviderModel(selection)) return;
const catalogProvider = CHATHUB_TO_CATALOG[selection.provider] ?? selection.provider;
const credentialId = credentialsByProvider.value?.[selection.provider] ?? '';
emit('update:config', {
model: `${catalogProvider}/${selection.model}`,
credential: credentialId,
});
}
function onSelectCredential(provider: ChatHubProvider, credentialId: string | null) {
selectCredential(provider, credentialId);
// If this is the currently selected provider, also update the config credential
if (props.config?.model) {
const parsed = parseModelString(props.config.model);
const currentChatHubProvider = parsed ? CATALOG_TO_CHATHUB[parsed.provider] : undefined;
if (currentChatHubProvider === provider) {
emit('update:config', { credential: credentialId ?? '' });
}
}
}
watch(
() => props.config,
(config) => {
if (!config) return;
instructions.value = config.instructions ?? '';
},
{ deep: true, immediate: true },
);
function onInstructionsChange(value: string) {
instructions.value = value;
emit('update:config', { instructions: value });
}
// --- Collapsible sections ---
const expandedSections = ref<Record<string, boolean>>({
triggers: false,
tools: false,
advanced: false,
code: false,
});
function toggleSection(section: string) {
expandedSections.value[section] = !expandedSections.value[section];
}
// --- Resizable width ---
const sidebarWidth = ref(480);
const MIN_WIDTH = 360;
const MAX_WIDTH = 700;
function onResizeStart(event: MouseEvent) {
const startX = event.clientX;
const startWidth = sidebarWidth.value;
function onMouseMove(e: MouseEvent) {
const delta = startX - e.clientX;
sidebarWidth.value = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startWidth + delta));
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
</script>
<template>
<aside
:class="$style.sidebar"
:style="{ width: `${sidebarWidth}px`, minWidth: `${MIN_WIDTH}px` }"
>
<!-- Resize handle -->
<div :class="$style.resizeHandle" @mousedown="onResizeStart" />
<!-- Sidebar header -->
<div :class="$style.header">
<N8nText tag="span" bold>{{ locale.baseText('agents.settings.title') }}</N8nText>
<div :class="$style.headerActions">
<button :class="$style.cancelBtn" :disabled="!isDirty" @click="emit('cancel')">
{{ locale.baseText('agents.settings.cancel') }}
</button>
<N8nButton
type="primary"
size="small"
:label="locale.baseText('agents.settings.save')"
:disabled="!isDirty"
@click="emit('save')"
/>
</div>
</div>
<!-- Unsaved changes banner -->
<N8nCallout v-if="isDirty" theme="warning" :class="$style.unsavedBanner">
{{ locale.baseText('agents.settings.unsavedChanges') }}
</N8nCallout>
<div :class="$style.body">
<!-- Model section -->
<div :class="[$style.staticSection, $style.modelSection]">
<div :class="$style.sectionLabel">
<N8nText tag="span" bold size="small">{{
locale.baseText('agents.settings.model')
}}</N8nText>
</div>
<ModelSelector
:selected-agent="selectedAgent"
:include-custom-agents="false"
:credentials="credentialsByProvider"
:agents="chatStore.agents"
:is-loading="false"
horizontal
@change="onModelChange"
@select-credential="onSelectCredential"
/>
</div>
<!-- Instructions section -->
<div :class="$style.staticSection">
<div :class="$style.sectionLabel">
<N8nText tag="span" bold size="small">{{
locale.baseText('agents.settings.instructions')
}}</N8nText>
</div>
<N8nInput
:model-value="instructions"
type="textarea"
:rows="6"
:placeholder="locale.baseText('agents.settings.instructions.placeholder')"
data-testid="agent-instructions-input"
@update:model-value="onInstructionsChange"
/>
</div>
<!-- Triggers (collapsible) -->
<div :class="$style.section">
<button :class="$style.sectionHeader" @click="toggleSection('triggers')">
<div :class="$style.sectionHeaderLeft">
<N8nIcon
:icon="expandedSections.triggers ? 'chevron-down' : 'chevron-right'"
:size="16"
/>
<N8nText tag="span" bold size="small">{{
locale.baseText('agents.settings.triggers')
}}</N8nText>
</div>
<span role="button" tabindex="0" :class="$style.addBtn" @click.stop @keydown.enter.stop>
<N8nIcon icon="plus" :size="16" />
</span>
</button>
<div v-if="expandedSections.triggers" :class="$style.sectionContent">
<N8nText size="small" color="text-light">
{{ locale.baseText('agents.settings.triggers.placeholder') }}
</N8nText>
</div>
</div>
<!-- Tools (collapsible) -->
<div :class="$style.section">
<button :class="$style.sectionHeader" @click="toggleSection('tools')">
<div :class="$style.sectionHeaderLeft">
<N8nIcon :icon="expandedSections.tools ? 'chevron-down' : 'chevron-right'" :size="16" />
<N8nText tag="span" bold size="small">{{
locale.baseText('agents.settings.tools')
}}</N8nText>
</div>
<span role="button" tabindex="0" :class="$style.addBtn" @click.stop @keydown.enter.stop>
<N8nIcon icon="plus" :size="16" />
</span>
</button>
<div v-if="expandedSections.tools" :class="$style.sectionContent">
<AgentToolsPanel
:config="config"
:agent-tools="agentTools"
@update:config="(changes) => emit('update:config', changes)"
/>
</div>
</div>
<!-- Advanced (collapsible) -->
<div :class="$style.section">
<button :class="$style.sectionHeader" @click="toggleSection('advanced')">
<div :class="$style.sectionHeaderLeft">
<N8nIcon
:icon="expandedSections.advanced ? 'chevron-down' : 'chevron-right'"
:size="16"
/>
<N8nText tag="span" bold size="small">{{
locale.baseText('agents.settings.advanced')
}}</N8nText>
</div>
<span role="button" tabindex="0" :class="$style.addBtn" @click.stop @keydown.enter.stop>
<N8nIcon icon="plus" :size="16" />
</span>
</button>
<div v-if="expandedSections.advanced" :class="$style.sectionContent">
<AgentMemoryPanel
:config="config"
@update:config="(changes) => emit('update:config', changes)"
/>
</div>
</div>
<!-- Config JSON (collapsed by default) -->
<div :class="$style.section">
<button :class="$style.sectionHeader" @click="toggleSection('code')">
<div :class="$style.sectionHeaderLeft">
<N8nIcon :icon="expandedSections.code ? 'chevron-down' : 'chevron-right'" :size="16" />
<N8nText tag="span" bold size="small">{{
locale.baseText('agents.settings.code')
}}</N8nText>
</div>
</button>
<div v-if="expandedSections.code" :class="$style.codeSection">
<AgentCodeEditor
:config="config"
:agent-tools="agentTools"
@update:config="(newConfig) => emit('update:config', newConfig)"
/>
</div>
</div>
</div>
</aside>
</template>
<style module>
.sidebar {
position: relative;
border-left: var(--border-width) var(--border-style) var(--color--foreground);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
.resizeHandle {
position: absolute;
top: 0;
left: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 5;
}
.resizeHandle:hover {
background-color: var(--color--primary--tint-2);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
min-height: 56px;
padding: 0 var(--spacing--sm);
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.cancelBtn {
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size--sm);
font-family: var(--font-family);
font-weight: var(--font-weight--bold);
color: var(--color--text);
padding: var(--spacing--4xs) var(--spacing--xs);
border-radius: var(--radius);
}
.cancelBtn:hover {
background-color: var(--color--foreground--tint-2);
}
.cancelBtn:disabled {
color: var(--color--text--tint-2);
cursor: default;
}
.cancelBtn:disabled:hover {
background: none;
}
.unsavedBanner {
flex-shrink: 0;
text-align: center;
border-radius: 0;
}
.body {
flex: 1;
overflow-y: auto;
}
.staticSection {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding: var(--spacing--sm);
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
}
.modelSection {
padding: var(--spacing--sm);
border-bottom: 0;
padding-bottom: 0;
}
.sectionLabel {
display: flex;
align-items: center;
justify-content: space-between;
}
.section {
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--spacing--xs) var(--spacing--sm);
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.sectionHeader:hover {
background-color: var(--color--foreground--tint-2);
}
.sectionHeaderLeft {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.addBtn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: none;
cursor: pointer;
color: var(--color--text--tint-1);
border-radius: var(--radius);
}
.addBtn:hover {
background-color: var(--color--foreground--tint-1);
color: var(--color--text);
}
.sectionContent {
padding: 0 var(--spacing--sm) var(--spacing--sm);
}
.codeSection {
height: 400px;
min-height: 300px;
}
</style>

View File

@ -163,11 +163,12 @@ function inputSchemaProperties(
.heading {
margin: 0;
color: var(--color--text);
}
.count {
font-weight: var(--font-weight--regular);
color: var(--color--text--tint-2);
color: var(--color--text--tint-1);
}
.emptyState {

View File

@ -1,6 +1,6 @@
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type { AgentSchema, AgentResource, AgentJsonConfig } from '../types';
import type { AgentResource, AgentJsonConfig } from '../types';
export const listAgents = async (
context: IRestApiContext,
@ -111,33 +111,6 @@ export const listAllAgents = async (
);
};
export const getAgentSchema = async (
context: IRestApiContext,
projectId: string,
agentId: string,
): Promise<AgentSchema> => {
return await makeRestApiRequest<AgentSchema>(
context,
'GET',
`/projects/${projectId}/agents/v2/${agentId}/schema`,
);
};
export const patchAgentSchema = async (
context: IRestApiContext,
projectId: string,
agentId: string,
schema: AgentSchema,
updatedAt: string,
): Promise<{ code: string; schema: AgentSchema; updatedAt: string }> => {
return await makeRestApiRequest<{ code: string; schema: AgentSchema; updatedAt: string }>(
context,
'PATCH',
`/projects/${projectId}/agents/v2/${agentId}/schema`,
{ schema, updatedAt },
);
};
export interface ModelInfo {
id: string;
name: string;

View File

@ -1,46 +0,0 @@
import { ref } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { getAgentSchema, patchAgentSchema } from './useAgentApi';
import type { AgentSchema } from '../types';
export function useAgentSchema() {
const rootStore = useRootStore();
const schema = ref<AgentSchema | null>(null);
const loading = ref(false);
async function fetchSchema(projectId: string, agentId: string) {
loading.value = true;
try {
schema.value = await getAgentSchema(rootStore.restApiContext, projectId, agentId);
} finally {
loading.value = false;
}
}
async function updateSchema(
projectId: string,
agentId: string,
fullSchema: AgentSchema,
updatedAt: string,
): Promise<{ updatedAt: string } | null> {
try {
const result = await patchAgentSchema(
rootStore.restApiContext,
projectId,
agentId,
fullSchema,
updatedAt,
);
schema.value = result.schema;
return { updatedAt: result.updatedAt };
} catch (error: unknown) {
if ((error as { httpStatusCode?: number }).httpStatusCode === 409) {
await fetchSchema(projectId, agentId);
return null;
}
throw error;
}
}
return { schema, loading, fetchSchema, updateSchema };
}

View File

@ -1,3 +1,4 @@
export const AGENTS_LIST_VIEW = 'AgentsListView';
export const AGENT_BUILDER_VIEW = 'AgentBuilderView';
export const PROJECT_AGENTS = 'ProjectAgents';
export const NEW_AGENT_VIEW = 'NewAgentView';

View File

@ -1,8 +1,14 @@
import { type FrontendModuleDescription } from '@/app/moduleInitializer/module.types';
import { AGENTS_LIST_VIEW, AGENT_BUILDER_VIEW, PROJECT_AGENTS } from '@/features/agents/constants';
import {
AGENTS_LIST_VIEW,
AGENT_BUILDER_VIEW,
NEW_AGENT_VIEW,
PROJECT_AGENTS,
} from '@/features/agents/constants';
const AgentsListView = async () => await import('@/features/agents/views/AgentsListView.vue');
const AgentBuilderView = async () => await import('@/features/agents/views/AgentBuilderView.vue');
const NewAgentView = async () => await import('@/features/agents/views/NewAgentView.vue');
export const AgentsModule: FrontendModuleDescription = {
id: 'agents',
@ -37,6 +43,14 @@ export const AgentsModule: FrontendModuleDescription = {
middleware: ['authenticated', 'custom'],
},
},
{
name: NEW_AGENT_VIEW,
path: '/new-agent',
component: NewAgentView,
meta: {
middleware: ['authenticated', 'custom'],
},
},
],
projectTabs: {
overview: [

View File

@ -0,0 +1,35 @@
import type { ChatHubLLMProvider } from '@n8n/api-types';
/**
* Maps ChatHub provider IDs (camelCase, e.g. 'xAiGrok') to Agent SDK catalog
* IDs (lowercase, e.g. 'xai') used by the models.dev catalog and the agent
* code's `.model('provider', 'model-name')` call.
*/
export const CHATHUB_TO_CATALOG: Record<string, string> = {
openai: 'openai',
anthropic: 'anthropic',
google: 'google',
ollama: 'ollama',
azureOpenAi: 'azure-openai',
azureEntraId: 'azure-openai',
awsBedrock: 'aws-bedrock',
vercelAiGateway: 'vercel',
xAiGrok: 'xai',
groq: 'groq',
openRouter: 'openrouter',
deepSeek: 'deepseek',
cohere: 'cohere',
mistralCloud: 'mistral',
};
/**
* Reverse mapping: catalog ID ChatHub provider ID.
* When multiple ChatHub IDs map to the same catalog ID (e.g. azureOpenAi and
* azureEntraId both map to 'azure-openai'), the first one wins.
*/
export const CATALOG_TO_CHATHUB: Record<string, ChatHubLLMProvider> = {};
for (const [chatHub, catalog] of Object.entries(CHATHUB_TO_CATALOG)) {
if (!(catalog in CATALOG_TO_CHATHUB)) {
CATALOG_TO_CHATHUB[catalog] = chatHub as ChatHubLLMProvider;
}
}

View File

@ -1,204 +1,237 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { N8nButton } from '@n8n/design-system';
import { N8nActionDropdown, N8nIcon, N8nText } from '@n8n/design-system';
import type { IconOrEmoji } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { getAgent, updateAgent } from '../composables/useAgentApi';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { getAgent, updateAgent, deleteAgent } from '../composables/useAgentApi';
import type { AgentResource, AgentJsonConfig } from '../types';
import { AGENTS_LIST_VIEW } from '../constants';
import { useAgentConfig } from '../composables/useAgentConfig';
import AgentSidebar from '../components/AgentSidebar.vue';
import { useMessage } from '@/app/composables/useMessage';
import { MODAL_CONFIRM } from '@/app/constants';
import { deepCopy } from 'n8n-workflow';
import { agentsEventBus } from '../agents.eventBus';
import AgentChatPanel from '../components/AgentChatPanel.vue';
import AgentCodeEditor from '../components/AgentCodeEditor.vue';
import AgentIntegrationsPanel from '../components/AgentIntegrationsPanel.vue';
import AgentOverviewPanel from '../components/AgentOverviewPanel.vue';
import AgentToolsPanel from '../components/AgentToolsPanel.vue';
import AgentPromptsPanel from '../components/AgentPromptsPanel.vue';
import AgentMemoryPanel from '../components/AgentMemoryPanel.vue';
import AgentHomeContent from '../components/AgentHomeContent.vue';
import AgentSettingsSidebar from '../components/AgentSettingsSidebar.vue';
const route = useRoute();
const router = useRouter();
const locale = useI18n();
const rootStore = useRootStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const message = useMessage();
const projectId = computed(
() => (route.params.projectId as string) ?? projectsStore.personalProject?.id ?? '',
);
const agentId = route.params.agentId as string;
const agentId = computed(() => route.params.agentId as string);
const activeTab = ref((route.query.tab as string) || 'overview');
// UI state
const chatActive = ref(false);
const settingsVisible = ref(true);
const agentName = ref('');
const agentDescription = ref<string | null>(null);
const agentIcon = ref<IconOrEmoji>({ type: 'icon', value: 'robot' });
const agent = ref<AgentResource | null>(null);
const updatedAt = ref<string>('');
const initialPrompt = ref<string | undefined>(undefined);
// Config
const { config, fetchConfig, updateConfig } = useAgentConfig();
const localConfig = ref<AgentJsonConfig | null>(null);
const originalConfigJson = ref('');
const isDirty = ref(false);
watch(activeTab, (tab) => {
void router.replace({ query: { ...route.query, tab } });
});
watch(
() => route.query.tab as string | undefined,
(tab) => {
if (tab && tab !== activeTab.value) activeTab.value = tab;
config,
(c) => {
if (c) {
localConfig.value = deepCopy(c);
originalConfigJson.value = JSON.stringify(c);
isDirty.value = false;
}
},
{ immediate: true },
);
const chatVisible = ref(true);
const agentName = ref('');
const agent = ref<AgentResource | null>(null);
const editingName = ref(false);
const isDirty = ref(false);
const saving = ref(false);
const { config, fetchConfig, updateConfig } = useAgentConfig();
async function fetchAgent() {
const data = await getAgent(rootStore.restApiContext, projectId.value, agentId);
const data = await getAgent(rootStore.restApiContext, projectId.value, agentId.value);
agent.value = data;
updatedAt.value = data.updatedAt;
agentName.value = data.name;
agentDescription.value = data.description ?? null;
}
function onConfigUpdate(partial: Partial<AgentJsonConfig>) {
// Optimistically merge so the UI stays responsive
if (config.value) {
Object.assign(config.value, partial);
if (partial.config !== undefined) {
config.value.config = { ...config.value.config, ...partial.config };
}
}
isDirty.value = true;
}
function onConfigJsonChange(newConfig: AgentJsonConfig) {
config.value = newConfig;
isDirty.value = true;
}
async function saveConfig() {
if (!config.value || saving.value) return;
saving.value = true;
try {
await updateConfig(projectId.value, agentId, config.value);
isDirty.value = false;
} finally {
saving.value = false;
}
}
async function saveName() {
if (!agentName.value.trim()) return;
const updated = await updateAgent(rootStore.restApiContext, projectId.value, agentId, {
name: agentName.value.trim(),
async function updateName(name: string) {
const updated = await updateAgent(rootStore.restApiContext, projectId.value, agentId.value, {
name,
});
if (updated) {
agent.value = updated;
agentName.value = updated.name;
updatedAt.value = updated.updatedAt;
agentsEventBus.emit('agentUpdated');
}
}
async function updateDescription(description: string) {
const updated = await updateAgent(rootStore.restApiContext, projectId.value, agentId.value, {
description,
} as Record<string, unknown>);
if (updated) {
agent.value = updated;
agentDescription.value = updated.description ?? null;
updatedAt.value = updated.updatedAt;
}
}
function startChat(msg: string) {
initialPrompt.value = msg;
chatActive.value = true;
telemetry.track('User started agent chat', { agent_id: agentId.value });
}
function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>) {
if (!localConfig.value) return;
Object.assign(localConfig.value, updates);
isDirty.value = JSON.stringify(localConfig.value) !== originalConfigJson.value;
}
async function saveConfig() {
if (!localConfig.value) return;
await updateConfig(projectId.value, agentId.value, localConfig.value);
originalConfigJson.value = JSON.stringify(localConfig.value);
isDirty.value = false;
telemetry.track('User saved agent settings', { agent_id: agentId.value });
}
function cancelConfig() {
if (config.value) {
localConfig.value = deepCopy(config.value);
isDirty.value = false;
telemetry.track('User cancelled agent settings', { agent_id: agentId.value });
}
editingName.value = false;
}
async function onConfigUpdated() {
await Promise.all([fetchAgent(), fetchConfig(projectId.value, agentId)]);
await Promise.all([fetchAgent(), fetchConfig(projectId.value, agentId.value)]);
isDirty.value = false;
}
function onNameKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
void saveName();
} else if (event.key === 'Escape') {
agentName.value = agent.value?.name ?? '';
editingName.value = false;
const headerActions = [{ id: 'delete', label: 'Delete agent' }];
async function onHeaderAction(action: string) {
if (action === 'delete') {
const confirmed = await message.confirm(
`Are you sure you want to delete "${agentName.value}"?`,
'Delete agent',
{ confirmButtonText: 'Delete', cancelButtonText: 'Cancel', type: 'warning' },
);
if (confirmed !== MODAL_CONFIRM) return;
await deleteAgent(rootStore.restApiContext, projectId.value, agentId.value);
void router.push({ name: AGENTS_LIST_VIEW, params: { projectId: projectId.value } });
}
}
function onKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
event.preventDefault();
if (isDirty.value) void saveConfig();
}
}
async function initialize() {
agent.value = null;
chatActive.value = false;
agentIcon.value = { type: 'icon', value: 'robot' };
initialPrompt.value = undefined;
localConfig.value = null;
originalConfigJson.value = '';
isDirty.value = false;
onMounted(async () => {
await fetchAgent();
await fetchConfig(projectId.value, agentId);
window.addEventListener('keydown', onKeydown);
});
await fetchConfig(projectId.value, agentId.value);
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
});
const prompt = route.query.prompt as string | undefined;
if (prompt) {
void router.replace({ query: { ...route.query, prompt: undefined } });
startChat(prompt);
}
}
watch(agentId, initialize, { immediate: true });
</script>
<template>
<div :class="$style.builder">
<AgentSidebar :active-tab="activeTab" @select="activeTab = $event" />
<div :class="$style.main">
<div :class="$style.nameBar">
<input
v-if="editingName"
v-model="agentName"
:class="$style.nameInput"
autofocus
@blur="saveName"
@keydown="onNameKeydown"
/>
<h2 v-else :class="$style.nameDisplay" @click="editingName = true">
{{ agentName || 'Untitled Agent' }}
</h2>
<div :class="$style.nameBarActions">
<span v-if="isDirty && !saving" :class="$style.unsavedDot" title="Unsaved changes" />
<N8nButton
v-if="isDirty || saving"
type="primary"
size="small"
:loading="saving"
:disabled="saving"
data-testid="save-config-btn"
@click="saveConfig"
<!-- Left column: center content with its own header -->
<div :class="$style.mainColumn">
<div :class="$style.mainHeader">
<div :class="$style.mainHeaderLeft">
<N8nIcon icon="robot" :size="16" />
<N8nText tag="span" bold>{{
agentName || locale.baseText('agents.home.untitledAgent')
}}</N8nText>
</div>
<div :class="$style.mainHeaderRight">
<button
v-if="chatActive"
:class="$style.toggleBtn"
data-testid="new-chat"
@click="chatActive = false"
>
{{ saving ? 'Saving…' : 'Save' }}
</N8nButton>
<N8nIcon icon="message-circle-plus" :size="16" />
</button>
<button
:class="[$style.toggleBtn, settingsVisible && $style.toggleBtnActive]"
data-testid="toggle-settings"
@click="settingsVisible = !settingsVisible"
>
<N8nIcon icon="panel-right" :size="16" />
</button>
<N8nActionDropdown
:items="headerActions"
activator-icon="ellipsis-vertical"
data-testid="agent-header-actions"
@select="onHeaderAction"
/>
</div>
</div>
<AgentOverviewPanel
v-if="activeTab === 'overview'"
:config="config"
@update:config="onConfigUpdate"
/>
<AgentPromptsPanel
v-else-if="activeTab === 'prompts'"
:config="config"
@update:config="onConfigUpdate"
/>
<AgentToolsPanel
v-else-if="activeTab === 'tools'"
:config="config"
:agent-tools="agent?.tools ?? {}"
@update:config="onConfigUpdate"
/>
<AgentMemoryPanel
v-else-if="activeTab === 'memory'"
:config="config"
@update:config="onConfigUpdate"
/>
<div v-else-if="activeTab === 'config'" :class="$style.configPanel">
<AgentCodeEditor
:config="config"
:agent-tools="agent?.tools ?? {}"
@update:config="onConfigJsonChange"
<div :class="$style.mainBody">
<AgentHomeContent
v-if="!chatActive"
:agent-name="agentName"
:agent-description="agentDescription"
:agent-icon="agentIcon"
:project-id="projectId"
:agent-id="agentId"
@send-message="startChat"
@update:name="updateName"
@update:description="updateDescription"
@update:icon="agentIcon = $event"
/>
<AgentChatPanel
v-else
:project-id="projectId"
:agent-id="agentId"
mode="inline"
:initial-message="initialPrompt"
@config-updated="onConfigUpdated"
/>
</div>
<AgentIntegrationsPanel
v-else-if="activeTab === 'integrations'"
:project-id="projectId"
:agent-id="agentId"
/>
</div>
<AgentChatPanel
:visible="chatVisible"
:project-id="projectId"
:agent-id="agentId"
@close="chatVisible = false"
@config-updated="onConfigUpdated"
<!-- Right column: settings sidebar with its own header -->
<AgentSettingsSidebar
v-if="settingsVisible"
:config="localConfig"
:agent-tools="agent?.tools ?? {}"
:updated-at="updatedAt"
:is-dirty="isDirty"
@update:config="onConfigFieldUpdate"
@save="saveConfig"
@cancel="cancelConfig"
/>
</div>
</template>
@ -211,68 +244,65 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.main {
.mainColumn {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.nameBar {
.mainHeader {
display: flex;
align-items: center;
gap: var(--spacing--sm);
padding: var(--spacing--2xs) var(--spacing--sm);
justify-content: space-between;
height: 56px;
min-height: 56px;
padding: 0 var(--spacing--sm);
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
background-color: var(--color--foreground--tint-2);
}
.nameBarActions {
.mainHeaderLeft {
display: flex;
align-items: center;
gap: var(--spacing--xs);
margin-left: auto;
}
.unsavedDot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--color--warning);
}
.nameDisplay {
font-size: var(--font-size--md);
font-weight: var(--font-weight--bold);
gap: var(--spacing--2xs);
color: var(--color--text);
margin: 0;
cursor: pointer;
padding: var(--spacing--4xs) var(--spacing--3xs);
border-radius: var(--radius);
}
.nameDisplay:hover {
background-color: var(--color--foreground--tint-1);
.mainHeaderRight {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
.nameInput {
font-size: var(--font-size--md);
font-weight: var(--font-weight--bold);
color: var(--color--text);
background-color: var(--color--foreground--tint-2);
border: var(--border-width) var(--border-style) var(--color--primary);
border-radius: var(--radius);
padding: var(--spacing--4xs) var(--spacing--3xs);
outline: none;
font-family: var(--font-family);
}
.configPanel {
.mainBody {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.toggleBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
cursor: pointer;
color: var(--color--text--tint-1);
border-radius: var(--radius);
}
.toggleBtn:hover {
background-color: var(--color--foreground--tint-2);
color: var(--color--text);
}
.toggleBtnActive {
color: var(--color--text);
background-color: var(--color--foreground--tint-1);
}
</style>

View File

@ -12,11 +12,14 @@ import { useProjectPages } from '@/features/collaboration/projects/composables/u
import { listAgents, deleteAgent } from '../composables/useAgentApi';
import type { AgentResource } from '../types';
import { AGENT_BUILDER_VIEW } from '../constants';
import { useMessage } from '@/app/composables/useMessage';
import { MODAL_CONFIRM } from '@/app/constants';
const route = useRoute();
const router = useRouter();
const rootStore = useRootStore();
const projectsStore = useProjectsStore();
const message = useMessage();
const insightsStore = useInsightsStore();
const projectPages = useProjectPages();
@ -56,6 +59,12 @@ function onSelectAgent(agentId: string) {
async function onCardAction(action: string, agent: AgentResource) {
if (action === 'delete') {
const confirmed = await message.confirm(
`Are you sure you want to delete "${agent.name}"?`,
'Delete agent',
{ confirmButtonText: 'Delete', cancelButtonText: 'Cancel', type: 'warning' },
);
if (confirmed !== MODAL_CONFIRM) return;
await deleteAgent(rootStore.restApiContext, projectId.value, agent.id);
allAgents.value = allAgents.value.filter((a) => a.id !== agent.id);
}
@ -128,6 +137,7 @@ onMounted(fetchAgents);
<div :class="$style.cardActions" @click.stop>
<N8nActionDropdown
:items="cardActions"
activator-icon="ellipsis-vertical"
data-testid="agent-card-actions"
@select="onCardAction($event, data)"
/>

View File

@ -0,0 +1,249 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { N8nButton, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { createAgent } from '../composables/useAgentApi';
import { AGENT_BUILDER_VIEW } from '../constants';
const router = useRouter();
const locale = useI18n();
const rootStore = useRootStore();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const projectId = computed(() => projectsStore.personalProject?.id ?? '');
const firstName = computed(() => usersStore.currentUser?.firstName ?? '');
const inputText = ref('');
const isCreating = ref(false);
interface SuggestionTemplate {
icon: string;
name: string;
description: string;
prompt: string;
}
const suggestions: SuggestionTemplate[] = [
{
icon: '🔍',
name: 'SEO Audit',
description:
'An SEO auditor. Give it a website URL and it crawls the pages, identifies issues, and suggests improvements.',
prompt:
'Create an SEO auditor agent. It should accept a website URL, crawl the pages, identify SEO issues like missing meta tags, broken links, slow load times, and suggest improvements.',
},
{
icon: '👋',
name: 'Recruiting Sourcer',
description:
'A recruiting sourcer. Give it a job description and it finds matching candidates from multiple platforms.',
prompt:
'Create a recruiting sourcer agent. It should accept a job description, search for matching candidates across platforms, and compile a shortlist with contact info and relevance scores.',
},
{
icon: '📬',
name: 'Inbox Sorter',
description:
'Sort your inbox, classifying emails by sender and marking them as read when they match your rules.',
prompt:
'Create an inbox sorter agent. It should classify incoming emails by sender and topic, apply user-defined rules to mark as read, label, or archive, and provide a daily summary.',
},
];
async function createBlank() {
if (isCreating.value) return;
isCreating.value = true;
try {
const agent = await createAgent(rootStore.restApiContext, projectId.value, 'New Agent');
telemetry.track('User created agent', {
agent_id: agent.id,
source: 'create_blank',
});
void router.push({
name: AGENT_BUILDER_VIEW,
params: { projectId: projectId.value, agentId: agent.id },
});
} finally {
isCreating.value = false;
}
}
async function submitDescription() {
if (isCreating.value || !inputText.value.trim()) return;
isCreating.value = true;
try {
const agent = await createAgent(rootStore.restApiContext, projectId.value, 'New Agent');
telemetry.track('User created agent', {
agent_id: agent.id,
source: 'description_prompt',
});
void router.push({
name: AGENT_BUILDER_VIEW,
params: { projectId: projectId.value, agentId: agent.id },
query: { prompt: inputText.value.trim() },
});
} finally {
isCreating.value = false;
}
}
function selectSuggestion(suggestion: SuggestionTemplate) {
inputText.value = suggestion.prompt;
telemetry.track('User selected agent suggestion', {
suggestion_name: suggestion.name,
});
}
</script>
<template>
<div :class="$style.page">
<div :class="$style.topBar">
<N8nText tag="span" bold size="large">{{ locale.baseText('agents.newAgent.title') }}</N8nText>
<N8nButton
:label="locale.baseText('agents.newAgent.createBlank')"
type="secondary"
size="medium"
icon="file"
:loading="isCreating"
data-testid="create-blank-agent"
@click="createBlank"
/>
</div>
<div :class="$style.center">
<h1 :class="$style.heading">
{{
locale.baseText('agents.newAgent.heading', {
interpolate: { name: firstName ? `, ${firstName}` : '' },
})
}}
</h1>
<div :class="$style.inputWrapper">
<ChatInputBase
v-model="inputText"
:placeholder="locale.baseText('agents.newAgent.placeholder')"
:is-streaming="false"
:can-submit="inputText.trim().length > 0 && !isCreating"
:show-voice="true"
:show-attach="false"
@submit="submitDescription"
/>
</div>
<div :class="$style.suggestions">
<N8nText :class="$style.suggestionsLabel" size="small" color="text-light">
{{ locale.baseText('agents.newAgent.suggestions') }}
</N8nText>
<div
v-for="suggestion in suggestions"
:key="suggestion.name"
:class="$style.suggestionCard"
data-testid="agent-suggestion-card"
@click="selectSuggestion(suggestion)"
>
<span :class="$style.suggestionIcon">{{ suggestion.icon }}</span>
<div :class="$style.suggestionContent">
<N8nText tag="span" bold size="small">{{ suggestion.name }}</N8nText>
<N8nText tag="span" size="small" color="text-light">
{{ suggestion.description }}
</N8nText>
</div>
</div>
</div>
</div>
</div>
</template>
<style module>
.page {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background-color: var(--color--background);
}
.topBar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--sm) var(--spacing--lg);
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
}
.center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing--2xl) var(--spacing--lg);
max-width: 640px;
margin: 0 auto;
width: 100%;
}
.heading {
font-size: var(--font-size--2xl);
font-weight: var(--font-weight--bold);
color: var(--color--text);
margin: 0 0 var(--spacing--lg);
text-align: center;
}
.inputWrapper {
width: 100%;
margin-bottom: var(--spacing--xl);
}
.suggestions {
width: 100%;
}
.suggestionsLabel {
display: block;
margin-bottom: var(--spacing--xs);
}
.suggestionCard {
display: flex;
align-items: flex-start;
gap: var(--spacing--xs);
padding: var(--spacing--xs) var(--spacing--sm);
border-radius: var(--radius--lg);
cursor: pointer;
transition: background-color 0.15s ease;
}
.suggestionCard:hover {
background-color: var(--color--foreground--tint-2);
}
.suggestionIcon {
font-size: var(--font-size--md);
line-height: var(--line-height--xl);
flex-shrink: 0;
}
.suggestionContent {
display: flex;
gap: var(--spacing--4xs);
min-width: 0;
}
.suggestionContent span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -40,6 +40,7 @@ const {
includeCustomAgents = true,
credentials,
text,
horizontal = false,
warnMissingCredentials = false,
agents,
isLoading,
@ -48,6 +49,8 @@ const {
includeCustomAgents?: boolean;
credentials: CredentialsMap | null;
text?: boolean;
/** Display trigger as a full-width horizontal row instead of compact stacked layout */
horizontal?: boolean;
warnMissingCredentials?: boolean;
agents: ChatModelsResponse;
isLoading: boolean;
@ -200,7 +203,7 @@ defineExpose({
<template #trigger>
<N8nButton
:variant="text ? 'ghost' : 'subtle'"
:class="$style.dropdownButton"
:class="[$style.dropdownButton, horizontal && $style.dropdownButtonHorizontal]"
:text="text"
data-test-id="chat-model-selector"
>
@ -209,11 +212,15 @@ defineExpose({
:size="credentialsName || !isCredentialsRequired ? 'md' : 'sm'"
:class="$style.icon"
/>
<div :class="$style.selected">
<div>
<div :class="[$style.selected, horizontal && $style.selectedHorizontal]">
<N8nText>
{{ truncateBeforeLast(selectedLabel, MAX_AGENT_NAME_CHARS) }}
</div>
<N8nText v-if="credentialsName" size="xsmall" color="text-light">
</N8nText>
<N8nText
v-if="credentialsName"
:size="horizontal ? 'small' : 'xsmall'"
color="text-light"
>
{{ truncateBeforeLast(credentialsName, MAX_AGENT_NAME_CHARS) }}
</N8nText>
<N8nText v-else-if="isCredentialsMissing" size="xsmall" color="danger">
@ -225,7 +232,11 @@ defineExpose({
{{ i18n.baseText('chatHub.agent.credentialsMissing') }}
</N8nText>
</div>
<N8nIcon icon="chevron-down" size="medium" />
<N8nIcon
:class="horizontal && $style.chevronHorizontal"
icon="chevron-down"
size="medium"
/>
</N8nButton>
</template>
@ -303,6 +314,46 @@ defineExpose({
gap: var(--spacing--4xs);
}
.dropdownButtonHorizontal {
width: 100%;
display: flex;
justify-content: stretch;
padding: var(--spacing--2xs) var(--spacing--xs);
border: var(--border-width) var(--border-style) var(--color--foreground);
border-radius: var(--radius--lg);
> div {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
&:hover {
border-color: var(--color--foreground--shade-1);
}
}
.selectedHorizontal {
flex-direction: row;
align-items: center;
gap: var(--spacing--xs);
flex: 1;
min-width: 0;
overflow: hidden;
> div {
font-weight: var(--font-weight--bold);
white-space: nowrap;
text-overflow: ellipsis;
}
}
.chevronHorizontal {
align-self: flex-end;
margin-bottom: var(--spacing--5xs);
}
.icon {
flex-shrink: 0;
margin-block: -4px;

View File

@ -109,7 +109,7 @@ function resolveArtifactName(artifact: ArtifactInfo): string {
<style lang="scss" module>
.reasoningTrigger {
color: var(--text-color--subtler);
color: var(--color--text--tint-2);
}
.reasoningContent {

View File

@ -83,7 +83,7 @@ function handleClick(e: MouseEvent) {
}
.metadata {
color: var(--text-color--subtler);
color: var(--color--text--tint-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@ -20,19 +20,16 @@ import type { IUser } from 'n8n-workflow';
import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
import { useUIStore } from '@/app/stores/ui.store';
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
import { AGENT_BUILDER_VIEW } from '@/features/agents/constants';
import { createAgent } from '@/features/agents/composables/useAgentApi';
import { NEW_AGENT_VIEW } from '@/features/agents/constants';
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
import { N8nButton, N8nHeading, N8nText, N8nTooltip } from '@n8n/design-system';
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useRootStore } from '@n8n/stores/useRootStore';
const route = useRoute();
const router = useRouter();
const rootStore = useRootStore();
const i18n = useI18n();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
@ -361,12 +358,8 @@ const actions: Record<ActionTypes, (projectId: string) => void> = {
uiStore.openModalWithData({ name: VARIABLE_MODAL_KEY, data: { mode: 'new' } });
telemetry.track('User clicked header add variable button');
},
[ACTION_TYPES.AGENT]: async (projectId: string) => {
const agent = await createAgent(rootStore.restApiContext, projectId, 'New Agent');
void router.push({
name: AGENT_BUILDER_VIEW,
params: { projectId, agentId: agent.id },
});
[ACTION_TYPES.AGENT]: () => {
void router.push({ name: NEW_AGENT_VIEW });
},
} as const;

View File

@ -1,3 +1,4 @@
import { flushPromises } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
@ -46,6 +47,26 @@ vi.mock('is-emoji-supported', () => ({
isEmojiSupported: () => true,
}));
vi.mock('@/features/agents/composables/useAgentApi', () => ({
listAllAgents: vi.fn().mockResolvedValue([
{ id: 'agent-1', name: 'SEO Auditor' },
{ id: 'agent-2', name: 'Inbox Sorter' },
]),
deleteAgent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/app/composables/useMessage', () => ({
useMessage: () => ({
confirm: vi.fn(),
alert: vi.fn(),
prompt: vi.fn(),
}),
}));
vi.mock('@/app/composables/useTelemetry', () => ({
useTelemetry: () => ({ track: vi.fn() }),
}));
const renderComponent = createComponentRenderer(ProjectsNavigation, {
global: {
plugins: [
@ -214,4 +235,46 @@ describe('ProjectsNavigation', () => {
// The shared menu item should not be rendered
expect(getByTestId('project-shared-menu-item')).toBeInTheDocument();
});
describe('Agents sidebar section', () => {
beforeEach(() => {
projectsStore.teamProjectsLimit = -1;
projectsStore.personalProject = createTestProject({ type: 'personal' });
});
it('should show agents section when agents module is active', async () => {
settingsStore.isModuleActive = vi.fn((module: string) => module === 'agents');
const { queryAllByTestId, queryByTestId } = renderComponent({
props: { collapsed: false },
});
// Wait for the agents fetch to resolve
await flushPromises();
expect(queryAllByTestId('agent-menu-item').length).toBeGreaterThan(0);
expect(queryByTestId('new-agent-menu-item')).toBeInTheDocument();
});
it('should hide agents section when agents module is not active', () => {
settingsStore.isModuleActive = vi.fn(() => false);
const { queryByTestId } = renderComponent({
props: { collapsed: false },
});
expect(queryByTestId('agent-menu-item')).not.toBeInTheDocument();
expect(queryByTestId('new-agent-menu-item')).not.toBeInTheDocument();
});
it('should hide agents section when sidebar is collapsed', () => {
settingsStore.isModuleActive = vi.fn((module: string) => module === 'agents');
const { queryByTestId } = renderComponent({
props: { collapsed: true },
});
expect(queryByTestId('new-agent-menu-item')).not.toBeInTheDocument();
});
});
});

View File

@ -1,19 +1,35 @@
<script lang="ts" setup>
import { useGlobalEntityCreation } from '@/app/composables/useGlobalEntityCreation';
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import { VIEWS } from '@/app/constants';
import { sourceControlEventBus } from '@/features/integrations/sourceControl.ee/sourceControl.eventBus';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { IMenuItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import { computed, onBeforeMount, onBeforeUnmount } from 'vue';
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useProjectsStore } from '../projects.store';
import type { ProjectListItem } from '../projects.types';
import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { AGENT_BUILDER_VIEW, NEW_AGENT_VIEW } from '@/features/agents/constants';
import { listAllAgents, deleteAgent } from '@/features/agents/composables/useAgentApi';
import { agentsEventBus } from '@/features/agents/agents.eventBus';
import { N8nMenuItem, N8nText } from '@n8n/design-system';
import {
BetaTag,
N8nActionDropdown,
N8nMenuItem,
N8nPopover,
N8nText,
N8nButton,
} from '@n8n/design-system';
import { hasPermission } from '@/app/utils/rbac/permissions';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useMessage } from '@/app/composables/useMessage';
import { MODAL_CONFIRM } from '@/app/constants';
type Props = {
collapsed: boolean;
@ -23,11 +39,16 @@ type Props = {
const props = defineProps<Props>();
const locale = useI18n();
const route = useRoute();
const globalEntityCreation = useGlobalEntityCreation();
const { isCalloutDismissed, dismissCallout } = useCalloutHelpers();
const projectsStore = useProjectsStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const rootStore = useRootStore();
const telemetry = useTelemetry();
const message = useMessage();
const displayProjects = computed(() => globalEntityCreation.displayProjects.value);
const isFoldersFeatureEnabled = computed(() => settingsStore.isFoldersFeatureEnabled);
@ -45,6 +66,82 @@ const hasMultipleVerifiedUsers = computed(
() => usersStore.allUsers.filter((user) => !user.isPendingUser).length > 1,
);
// Agents sidebar section
// Gate: isModuleActive('agents') is false when the module isn't registered.
// The PostHog flag (0XX_first_class_agents) controls module registration on the
// backend side when the flag is off, 'agents' won't appear in activeModules.
const isAgentsAvailable = computed(() => settingsStore.isModuleActive('agents'));
const agentsList = ref<Array<{ id: string; name: string }>>([]);
const isCoachmarkVisible = computed(
() => isAgentsAvailable.value && !isCalloutDismissed('agents_sidebar_coachmark'),
);
const activeAgentId = computed(() => {
if (route.name === AGENT_BUILDER_VIEW) {
return route.params.agentId as string;
}
return undefined;
});
const getAgentMenuItem = (agent: { id: string; name: string }): IMenuItem => ({
id: `agent-${agent.id}`,
label: agent.name,
icon: { type: 'icon', value: 'robot' },
route: {
to: {
name: AGENT_BUILDER_VIEW,
params: {
projectId: projectsStore.personalProject?.id,
agentId: agent.id,
},
},
},
});
const newAgentItem = computed<IMenuItem>(() => ({
id: 'new-agent',
label: 'New agent',
icon: { type: 'icon', value: 'plus' },
route: { to: { name: NEW_AGENT_VIEW } },
}));
const agentActions = [{ id: 'delete', label: 'Delete' }];
async function fetchAgents() {
if (!isAgentsAvailable.value) return;
const personalProjectId = projectsStore.personalProject?.id;
if (!personalProjectId) return;
try {
agentsList.value = await listAllAgents(rootStore.restApiContext, personalProjectId);
} catch {
agentsList.value = [];
}
}
async function onAgentAction(action: string, agent: { id: string; name: string }) {
if (action === 'delete') {
const confirmed = await message.confirm(
`Are you sure you want to delete "${agent.name}"?`,
'Delete agent',
{ confirmButtonText: 'Delete', cancelButtonText: 'Cancel', type: 'warning' },
);
if (confirmed !== MODAL_CONFIRM) return;
const personalProjectId = projectsStore.personalProject?.id ?? '';
await deleteAgent(rootStore.restApiContext, personalProjectId, agent.id);
agentsList.value = agentsList.value.filter((a) => a.id !== agent.id);
}
}
// Refresh agents list when returning from agent builder
watch(
() => route.name,
() => {
if (isAgentsAvailable.value) {
void fetchAgents();
}
},
);
const home = computed<IMenuItem>(() => ({
id: 'home',
label: locale.baseText('projects.menu.overview'),
@ -119,10 +216,13 @@ async function onSourceControlPull() {
onBeforeMount(async () => {
await usersStore.fetchUsers({ filter: { isPending: false }, take: 2 });
sourceControlEventBus.on('pull', onSourceControlPull);
agentsEventBus.on('agentUpdated', fetchAgents);
void fetchAgents();
});
onBeforeUnmount(() => {
sourceControlEventBus.off('pull', onSourceControlPull);
agentsEventBus.off('agentUpdated', fetchAgents);
});
</script>
@ -167,6 +267,55 @@ onBeforeUnmount(() => {
data-test-id="project-chat-menu-item"
/>
</div>
<template v-if="isAgentsAvailable && !props.collapsed">
<N8nPopover :open="isCoachmarkVisible">
<template #trigger>
<div :class="$style.agentsLabel">
<N8nText size="small" bold color="text-light">{{
locale.baseText('agents.sidebar.label')
}}</N8nText>
<BetaTag />
</div>
</template>
<div :class="$style.coachmark">
<N8nText size="small">
{{ locale.baseText('agents.sidebar.coachmark') }}
</N8nText>
<N8nButton
size="mini"
type="secondary"
:label="locale.baseText('agents.sidebar.coachmark.dismiss')"
@click="
dismissCallout('agents_sidebar_coachmark');
telemetry.track('User dismissed agents coachmark');
"
/>
</div>
</N8nPopover>
<div :class="$style.agentItems">
<N8nMenuItem
:item="newAgentItem"
:compact="false"
:active="false"
data-test-id="new-agent-menu-item"
/>
<div v-for="agent in agentsList" :key="agent.id" :class="$style.agentItem">
<N8nMenuItem
:item="getAgentMenuItem(agent)"
:compact="false"
:active="activeAgentId === agent.id"
data-test-id="agent-menu-item"
/>
<div :class="$style.agentItemActions" @click.stop>
<N8nActionDropdown
:items="agentActions"
activator-icon="ellipsis-vertical"
@select="onAgentAction($event, agent)"
/>
</div>
</div>
</div>
</template>
<N8nText
v-if="
!props.collapsed && projectsStore.isTeamProjectFeatureEnabled && displayProjects.length > 0
@ -259,4 +408,43 @@ onBeforeUnmount(() => {
border-bottom: var(--border);
}
}
.agentsLabel {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
padding: 0 var(--spacing--xs);
margin-top: var(--spacing--2xs);
}
.agentItems {
padding: var(--spacing--4xs) var(--spacing--3xs);
}
.agentItem {
position: relative;
.agentItemActions {
position: absolute;
right: var(--spacing--3xs);
top: 50%;
transform: translateY(-50%);
opacity: 0;
pointer-events: none;
}
&:hover .agentItemActions,
&:focus-within .agentItemActions {
opacity: 1;
pointer-events: auto;
}
}
.coachmark {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs);
max-width: 240px;
}
</style>