From b37fdc2d3a5bf0706eda922e1a35ba24b9385d09 Mon Sep 17 00:00:00 2001 From: oleg Date: Fri, 18 Sep 2026 09:34:46 +0000 Subject: [PATCH] feat(editor): Add inline credential setup to n8n Assistant (no-changelog) (#38684) --- packages/@n8n/agents/src/index.ts | 1 + packages/@n8n/agents/src/skills/index.ts | 1 + packages/@n8n/agents/src/skills/tools.ts | 8 +- packages/@n8n/agents/src/skills/types.ts | 3 + .../__tests__/agent-run-reducer.test.ts | 27 + .../src/schemas/agent-run-reducer.ts | 5 + .../src/schemas/instance-ai.schema.ts | 2 + .../skills/post-build-flow/SKILL.md | 3 + .../skills/workflow-builder/SKILL.md | 4 +- .../system-prompt.setup-panel.test.ts | 3 +- .../instance-ai/src/agent/system-prompt.ts | 2 +- .../materialize-runtime-skills.test.ts | 6 +- .../src/skills/materialize-runtime-skills.ts | 6 +- .../instance-ai/src/tools/credentials.tool.ts | 2 +- .../instance-ai/src/tools/workflows.tool.ts | 2 +- .../src/tools/workflows/setup-panel-state.ts | 2 +- .../workflow-loop/__tests__/guidance.test.ts | 2 +- .../instance-ai/src/workflow-loop/guidance.ts | 2 +- .../@n8n/telemetry/src/events/credentials.ts | 26 + .../@n8n/telemetry/src/events/instance-ai.ts | 41 + .../@n8n/telemetry/src/events/workflow.ts | 10 + .../SetupConnection.stories.ts | 29 + .../SetupConnection.test.ts | 62 ++ .../SetupConnection.types.ts | 23 + .../N8nSetupConnection/SetupConnection.vue | 153 +++ .../N8nSetupPanel/SetupPanel.stories.ts | 356 +++++++ .../N8nSetupPanel/SetupPanel.test.ts | 239 +++++ .../N8nSetupPanel/SetupPanel.types.ts | 25 + .../components/N8nSetupPanel/SetupPanel.vue | 444 +++++++++ .../design-system/src/components/index.ts | 4 + .../@n8n/design-system/src/locale/lang/en.ts | 11 + .../frontend/@n8n/i18n/src/locales/en.json | 36 +- .../useInstanceAiEditorCapability.ts | 2 + .../src/app/composables/useRunWorkflow.ts | 34 +- .../src/app/composables/useRunWorkflowApi.ts | 46 + .../editor-ui/src/app/constants/durations.ts | 3 + .../src/app/constants/injectionKeys.ts | 4 + .../src/app/constants/localStorage.ts | 10 + .../editor-ui/src/app/constants/nodeTypes.ts | 9 + .../useChatHubMarkdownOptions.test.ts | 8 +- .../composables/useChatHubMarkdownOptions.ts | 1 + .../ai/instanceAi/InstanceAiThreadView.vue | 8 +- .../__tests__/InstanceAiThreadView.test.ts | 36 + .../__tests__/canvasPreview.utils.test.ts | 26 + .../createThreadComponentRenderer.ts | 2 + .../instanceAi.threadRuntime.test.ts | 62 ++ .../__tests__/setupPanelGroups.test.ts | 65 ++ .../__tests__/useSetupPanelActions.test.ts | 156 ++- .../__tests__/useSetupPanelExecution.test.ts | 422 ++++++++ .../__tests__/useSetupPanelState.test.ts | 48 +- .../__tests__/useSetupPanelTelemetry.test.ts | 170 ++++ .../ai/instanceAi/canvasPreview.utils.ts | 22 +- .../InstanceAiConfirmationPanel.vue | 19 + .../components/InstanceAiConversation.vue | 30 +- .../components/InstanceAiWorkflowPreview.vue | 3 + .../setupPanel/InstanceAiSetupCredential.vue | 751 +++++++++++++++ .../setupPanel/InstanceAiSetupPanel.vue | 907 ++++++++++++++---- .../setupPanel/InstanceAiSetupPanelDetail.vue | 262 ++--- .../InstanceAiSetupCredential.test.ts | 843 ++++++++++++++++ .../InstanceAiSetupPanel.interactions.test.ts | 473 ++++++++- .../__tests__/InstanceAiSetupPanel.test.ts | 640 +++++++++++- .../composables/useInstanceAiHandoff.ts | 3 +- .../composables/useSetupPanelActions.ts | 260 +++-- .../composables/useSetupPanelDocument.ts | 52 + .../composables/useSetupPanelExecution.ts | 238 +++++ .../composables/useSetupPanelState.ts | 46 +- .../composables/useSetupPanelTelemetry.ts | 163 ++++ .../ai/instanceAi/instanceAi.threadRuntime.ts | 16 + .../ai/instanceAi/setupPanelGroups.ts | 64 ++ .../CredentialEdit/CredentialInputs.test.ts | 90 ++ .../CredentialEdit/CredentialInputs.vue | 87 +- .../TemplatedAuthSimpleView.vue | 25 +- .../components/NodeCredentials.test.ts | 22 +- .../components/NodeCredentials.vue | 35 +- .../__tests__/useCredentialOAuth.test.ts | 44 + .../composables/useCredentialForm.ts | 5 +- .../composables/useCredentialOAuth.ts | 41 +- .../features/credentials/credentials.store.ts | 13 +- .../composables/useQuickConnect.ts | 16 +- .../AgentSelectorParameterInput.test.ts | 40 + .../AgentSelectorParameterInput.vue | 25 +- .../AssignmentCollection.vue | 11 +- .../parameters/components/ParameterInput.vue | 19 +- .../components/ParameterInputExpanded.vue | 3 +- .../components/ParameterInputList.vue | 1 + .../ResourceLocator/ResourceLocator.test.ts | 25 + .../ResourceLocator/ResourceLocator.vue | 24 +- .../ResourceLocatorDropdown.vue | 6 +- .../WorkflowSelectorParameterInput.test.ts | 61 ++ .../WorkflowSelectorParameterInput.vue | 31 +- .../composables/useWorkflowSetupItems.test.ts | 177 +++- .../composables/useWorkflowSetupItems.ts | 138 ++- .../setupPanel/setupPanel.utils.test.ts | 18 + .../features/setupPanel/setupPanel.utils.ts | 10 +- .../parts/CanvasNodeStatusIcons.test.ts | 52 +- .../parts/CanvasNodeStatusIcons.vue | 37 +- .../credentials/ClearbitApi.credentials.ts | 1 + .../credentials/NotionApi.credentials.ts | 1 + .../credentials/SlackApi.credentials.ts | 2 + .../credentials/TelegramApi.credentials.ts | 1 + .../nodes/Slack/V2/GenericFunctions.ts | 3 + .../Slack/test/v2/GenericFunctions.test.ts | 39 + 102 files changed, 7743 insertions(+), 804 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.types.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.types.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.vue create mode 100644 packages/frontend/editor-ui/src/app/composables/useRunWorkflowApi.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/setupPanelGroups.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useSetupPanelExecution.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useSetupPanelTelemetry.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/setupPanel/InstanceAiSetupCredential.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/setupPanel/__tests__/InstanceAiSetupCredential.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useSetupPanelDocument.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useSetupPanelExecution.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useSetupPanelTelemetry.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/setupPanelGroups.ts create mode 100644 packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialInputs.test.ts diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index 166b1269788..9daf174a7bd 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -197,6 +197,7 @@ export { renderSkillCatalogPrompt, RUNTIME_SKILL_TOOL_NAMES, RUNTIME_SKILL_FILE_NAME, + RUNTIME_SKILL_MAX_OUTPUT_BYTES, RUNTIME_SKILL_LINKED_FILE_GROUPS, RUNTIME_SKILL_NAME_PATTERN, RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION, diff --git a/packages/@n8n/agents/src/skills/index.ts b/packages/@n8n/agents/src/skills/index.ts index 6bbedc2acb4..3c4c9ebb026 100644 --- a/packages/@n8n/agents/src/skills/index.ts +++ b/packages/@n8n/agents/src/skills/index.ts @@ -1,5 +1,6 @@ export { RUNTIME_SKILL_FILE_NAME, + RUNTIME_SKILL_MAX_OUTPUT_BYTES, RUNTIME_SKILL_LINKED_FILE_GROUPS, RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION, SKILL_LOAD_TOOL_NAME, diff --git a/packages/@n8n/agents/src/skills/tools.ts b/packages/@n8n/agents/src/skills/tools.ts index 7f9cc0d190c..9c2fe780318 100644 --- a/packages/@n8n/agents/src/skills/tools.ts +++ b/packages/@n8n/agents/src/skills/tools.ts @@ -5,6 +5,7 @@ import { Tool } from '../sdk/tool'; import type { BuiltTool } from '../types'; import { RUNTIME_SKILL_FILE_NAME, + RUNTIME_SKILL_MAX_OUTPUT_BYTES, SKILL_LOAD_TOOL_NAME, type RuntimeSkillLinkedFile, type RuntimeSkillLinkedFiles, @@ -15,8 +16,7 @@ import { type RuntimeSkillContent, } from './types'; -const MAX_OUTPUT_BYTES = 64 * 1024; -const TRUNCATION_FOOTER = '\n\n[... output truncated to 64 KB ...]'; +const TRUNCATION_FOOTER = `\n\n[... output truncated to ${RUNTIME_SKILL_MAX_OUTPUT_BYTES / 1024} KB ...]`; const LINKED_FILE_GROUPS: Array = [ 'references', 'templates', @@ -315,6 +315,6 @@ function envelopeValue(value: string): string { function cap(content: string): string { const redacted = scrubSecretsInText(content); const bytes = Buffer.from(redacted, 'utf8'); - if (bytes.byteLength <= MAX_OUTPUT_BYTES) return redacted; - return `${bytes.subarray(0, MAX_OUTPUT_BYTES).toString('utf8')}${TRUNCATION_FOOTER}`; + if (bytes.byteLength <= RUNTIME_SKILL_MAX_OUTPUT_BYTES) return redacted; + return `${bytes.subarray(0, RUNTIME_SKILL_MAX_OUTPUT_BYTES).toString('utf8')}${TRUNCATION_FOOTER}`; } diff --git a/packages/@n8n/agents/src/skills/types.ts b/packages/@n8n/agents/src/skills/types.ts index 2206226ca61..67d87660b13 100644 --- a/packages/@n8n/agents/src/skills/types.ts +++ b/packages/@n8n/agents/src/skills/types.ts @@ -2,6 +2,9 @@ export const RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION = 1 as const; export const RUNTIME_SKILL_FILE_NAME = 'SKILL.md'; +/** Maximum UTF-8 bytes of instruction or linked-file content before truncation. */ +export const RUNTIME_SKILL_MAX_OUTPUT_BYTES = 72 * 1024; + export const SKILL_LOAD_TOOL_NAME = 'load_skill'; export const RUNTIME_SKILL_LINKED_FILE_GROUPS = [ diff --git a/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts index d1e2e72faca..059672feed2 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts @@ -904,6 +904,11 @@ describe('agent-run-reducer', () => { reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'slackApi')); reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'notionApi')); reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-2', 'gmailOAuth2')); + reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'notionApi')); + expect(state.agentsById['root'].latestSetupAnnouncement).toMatchObject({ + workflowId: 'wf-1', + agentId: 'root', + }); const byWorkflowId = state.agentsById['root'].setupItemsByWorkflowId!; expect(byWorkflowId['wf-1']).toHaveLength(1); @@ -918,6 +923,25 @@ describe('agent-run-reducer', () => { expect(state.agentsById['root'].setupItemsByWorkflowId?.['wf-1']).toHaveLength(1); expect(state.agentsById['sub-1'].setupItemsByWorkflowId).toBeUndefined(); + expect(state.agentsById['root'].latestSetupAnnouncement?.agentId).toBe('sub-1'); + }); + + it('preserves the emitting agent identity across an aliased follow-up run', () => { + const state = stateWithRun('run-1', 'root'); + reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'slackApi')); + reduceEvent(state, makeRunFinish('run-1', 'root', 'completed')); + reduceEvent(state, makeRunStart('run-2', 'follow-up-root')); + reduceEvent(state, makeToolCall('run-2', 'follow-up-root', 'build-2', 'build-workflow')); + reduceEvent(state, makeSetupItems('run-2', 'follow-up-root', 'wf-2', 'notionApi')); + + const root = toAgentTree(state); + expect(root.latestSetupAnnouncement).toMatchObject({ + workflowId: 'wf-2', + agentId: root.agentId, + }); + expect(root.toolCalls).toContainEqual( + expect.objectContaining({ toolCallId: 'build-2', isLoading: true }), + ); }); it('survives a tree snapshot round trip (history restore)', () => { @@ -932,6 +956,9 @@ describe('agent-run-reducer', () => { expect(restored?.agentsById['root'].setupItemsByWorkflowId?.['wf-1'][0]).toMatchObject({ credentialType: 'slackApi', }); + expect(restored?.agentsById['root'].latestSetupAnnouncement).toEqual( + state.agentsById['root'].latestSetupAnnouncement, + ); }); it('is preserved across a follow-up run-start when it is the only content', () => { diff --git a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts index caaa81905bb..eef07d49e82 100644 --- a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts +++ b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts @@ -499,6 +499,11 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent // semantics: last event wins per workflowId. const root = ensureAgent(state, state.rootAgentId); if (root && isSafeObjectKey(event.payload.workflowId)) { + root.latestSetupAnnouncement = { + workflowId: event.payload.workflowId, + agentId: ensureAgent(state, event.agentId)?.agentId ?? event.agentId, + timestamp: eventTimestamp(event), + }; root.setupItemsByWorkflowId = { ...root.setupItemsByWorkflowId, [event.payload.workflowId]: event.payload.items, diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index 47fce92b355..c83bc0ce745 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -1795,6 +1795,8 @@ export interface InstanceAiAgentNode { * regardless of which agent emitted. */ setupItemsByWorkflowId?: Record; + /** Latest setup announcement, including its emitting agent and replay-stable time. */ + latestSetupAnnouncement?: { workflowId: string; agentId: string; timestamp: string }; result?: string; error?: string; errorDetails?: { diff --git a/packages/@n8n/instance-ai/skills/post-build-flow/SKILL.md b/packages/@n8n/instance-ai/skills/post-build-flow/SKILL.md index 201ffcd7b17..fb71d3319ff 100644 --- a/packages/@n8n/instance-ai/skills/post-build-flow/SKILL.md +++ b/packages/@n8n/instance-ai/skills/post-build-flow/SKILL.md @@ -39,6 +39,9 @@ Use this section when the system prompt describes the persistent setup panel, setup returns `announced: true`, or the current user input contains ``. Otherwise, keep the setup card flow below. +- Setup requirements can appear while the workflow is being built. The user can + complete them immediately. Refer to the "setup panel" without a position or + a claim that setup must wait until the build finishes. - Verify what the build can simulate before asking the user to finish setup. Missing credentials do not prevent this verification. Report which outputs were simulated. A simulated result does not prove a live connection. diff --git a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md index 859b2f1b5b7..ddfb51f48c6 100644 --- a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md @@ -516,7 +516,9 @@ When `nodes(action="explore-resources")` returns no results for a required resource: 1. If the resource can be represented as a user choice, use - `placeholder('Select ')` and let setup collect it after the build. + `placeholder('Select ')` and let setup collect it. When the persistent + setup panel is enabled, the user can fill announced requirements during the + build. Do not tell them to wait until the build finishes. 2. If the user explicitly asked you to create the resource and the node type definition has a safe create operation, build and verify that resource-creation workflow as part of the requested work. diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.setup-panel.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.setup-panel.test.ts index 5b6649e2201..c03670f508a 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.setup-panel.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.setup-panel.test.ts @@ -18,7 +18,8 @@ describe('getSystemPrompt — setup panel', () => { const prompt = getSystemPrompt({ setupPanelEnabled: true }); expect(prompt).not.toContain('opens the inline setup card'); - expect(prompt).toContain('setup panel next to the chat'); + expect(prompt).toContain('setup panel while the workflow is being built'); + expect(prompt).not.toContain('next to the chat'); expect(prompt).toContain(''); expect(prompt).toContain('end your turn'); expect(prompt).toContain('When the result has `announced: true`'); diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index f8b8b0fd669..fd89d4c7fab 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -210,7 +210,7 @@ If the user asks for a blocked operation, explain that the instance is in read-o */ function getCredentialSetupBullet(setupPanelEnabled?: boolean): string { if (setupPanelEnabled) { - return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available. When the result has `announced: true`, the setup panel next to the chat lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.'; + return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available. Requirements can appear in the setup panel while the workflow is being built. The user can complete them immediately. Do not describe setup as happening only after the build. When the result has `announced: true`, the setup panel lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.'; } return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available — it opens the inline setup card in the n8n Assistant panel and handles credentials, parameters, and triggers in one step. Use `credentials(action="setup")` only when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor. Setup cards are only open while the setup call is pending — once it returns a result, the card is resolved: describe the outcome (e.g. credentials selected and ready), never that a card is open or that the user still needs to authorize. When a node in `nodesStillNeedingSetup` carries `parameterIssues`, the connected credential can\'t reach the value that was configured (e.g. a model outside what the credential allows) — fix the value, then tell the user plainly which value didn\'t work and what you set instead. Never silently swap a model or other parameter without saying so. Nodes listed under `skippedByUser` are different: the user chose to skip them, so never re-open the setup card for those — say what stays unconfigured and offer to set it up later.'; } diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts index 1441bff6e0d..051bcf3a6e8 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts @@ -1,4 +1,5 @@ import { + RUNTIME_SKILL_MAX_OUTPUT_BYTES, RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION, createSkillLoadTool, type RuntimeSkillLinkedFiles, @@ -356,7 +357,6 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { }); it('warns when materialized skill files exceed the load_skill output limit', async () => { - const runtimeSkillMaxOutputBytes = 64 * 1024; const source: RuntimeSkillSource = { registry: { schemaVersion: RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION, @@ -376,7 +376,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { id: 'large-skill', name: 'large-skill', description: 'Large skill', - instructions: 'x'.repeat(runtimeSkillMaxOutputBytes + 1), + instructions: 'x'.repeat(RUNTIME_SKILL_MAX_OUTPUT_BYTES + 1), }), }; const { workspace } = createMockWorkspace(); @@ -399,7 +399,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { const [message, meta] = limitWarnCall!; expect(message).toBe('Runtime skill file exceeds load_skill output limit'); expect(meta?.skill).toBe('large-skill'); - expect(meta?.maxBytes).toBe(runtimeSkillMaxOutputBytes); + expect(meta?.maxBytes).toBe(RUNTIME_SKILL_MAX_OUTPUT_BYTES); }); }); diff --git a/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts b/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts index c24db21a81d..fb10bdce8b2 100644 --- a/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts +++ b/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts @@ -1,5 +1,6 @@ import { RUNTIME_SKILL_FILE_NAME, + RUNTIME_SKILL_MAX_OUTPUT_BYTES, type RuntimeSkillContent, type RuntimeSkillDependenciesContract, type RuntimeSkillInterfaceContract, @@ -91,7 +92,6 @@ const LINKED_FILE_GROUPS = [ const N8N_SKILL_DIR_TEMPLATE = '$' + '{N8N_SKILL_DIR}'; const N8N_SKILLS_DIR_TEMPLATE = '$' + '{N8N_SKILLS_DIR}'; const N8N_WORKSPACE_DIR_TEMPLATE = '$' + '{N8N_WORKSPACE_DIR}'; -const LOAD_SKILL_OUTPUT_LIMIT_BYTES = 64 * 1024; function isNonEmptyRecord(value: Record): boolean { return Object.keys(value).length > 0; @@ -410,13 +410,13 @@ function warnIfExceedsLoadSkillLimit( content: string, ): void { const bytes = Buffer.byteLength(content, 'utf8'); - if (bytes <= LOAD_SKILL_OUTPUT_LIMIT_BYTES) return; + if (bytes <= RUNTIME_SKILL_MAX_OUTPUT_BYTES) return; logger.warn('Runtime skill file exceeds load_skill output limit', { skill: entry.name, path: filePath, bytes, - maxBytes: LOAD_SKILL_OUTPUT_LIMIT_BYTES, + maxBytes: RUNTIME_SKILL_MAX_OUTPUT_BYTES, }); } diff --git a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts index a535b1d7977..3b85581af98 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts @@ -804,7 +804,7 @@ async function announceSetupItems( workflowId, credentials: existingByType, message: - `Listed ${typeNames} in the setup panel next to the chat. No card is open and nothing is waiting on ` + + `Listed ${typeNames} in the setup panel. No card is open and nothing is waiting on ` + 'you: continue the build. The user can connect these at any time while you work — the build ' + 'attaches a matching stored credential automatically and the panel tracks what remains. Do not ' + 'ask the user to connect them now, do not describe a card, and do not call setup again for these ' + diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index aac433a4d47..a9695fa1f03 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -1493,7 +1493,7 @@ async function resolveUnverifiedPublishDisclosure( } const SETUP_PANEL_ANNOUNCED_GUIDANCE = - 'The setup panel next to the chat now lists what this workflow still needs (`open`); nothing is ' + + 'The setup panel now lists what this workflow still needs (`open`); nothing is ' + 'waiting on you and no card is open. Finish your turn now: tell the user in one or two sentences ' + 'what to configure in the panel — name the services and any values — then stop. Do not call setup ' + 'again for this workflow, do not call `credentials(action="setup")`, and do not tell the user to ' + diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-panel-state.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-panel-state.ts index 140a6a1ef3b..42c07171a8e 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-panel-state.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-panel-state.ts @@ -270,7 +270,7 @@ export function formatWorkflowSetupStateNote( })); return [ 'Setup state of the workflows this conversation built, recomputed just now from the saved ' + - 'workflows and the credentials in this project. The setup panel next to the chat shows the ' + + 'workflows and the credentials in this project. The setup panel shows the ' + 'current checklist and the user completes items there. This observation does not test connections ' + 'or resource availability. A configured item is not proof of a successful connection test or ' + 'workflow execution. Never ask ' + diff --git a/packages/@n8n/instance-ai/src/workflow-loop/__tests__/guidance.test.ts b/packages/@n8n/instance-ai/src/workflow-loop/__tests__/guidance.test.ts index 70770bfb9a7..d9154a07aa7 100644 --- a/packages/@n8n/instance-ai/src/workflow-loop/__tests__/guidance.test.ts +++ b/packages/@n8n/instance-ai/src/workflow-loop/__tests__/guidance.test.ts @@ -553,7 +553,7 @@ describe('formatWorkflowLoopGuidance — setup panel', () => { expect(result).toContain('workflows(action="setup")'); expect(result).toContain('wf-123'); - expect(result).toContain('setup panel next to the chat'); + expect(result).toContain('setup panel'); expect(result).toContain('end your turn'); expect(result).toContain('When the result has `announced: true`'); expect(result).toContain('Otherwise follow the returned guidance'); diff --git a/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts b/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts index 1a80900ddf3..ad63bea0e18 100644 --- a/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts +++ b/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts @@ -97,7 +97,7 @@ export function formatWorkflowLoopGuidance( return ( `${claimLead} It still uses temporary mock data. ` + `Call \`workflows(action="setup")\` with workflowId "${action.workflowId ?? 'unknown'}" once: ` + - 'it lists the remaining credentials and values in the setup panel next to the chat and returns them to you. ' + + 'it lists the remaining credentials and values in the setup panel and returns them to you. ' + 'When the result has `announced: true`, summarize it, report any validation warnings, and end your turn. ' + 'Otherwise follow the returned guidance for validation errors, approvals, skipped items, or an existing setup card. ' + 'Do not call `credentials(action="setup")` or `apply-workflow-credentials`, and do not tell the user to open the editor or canvas.' diff --git a/packages/@n8n/telemetry/src/events/credentials.ts b/packages/@n8n/telemetry/src/events/credentials.ts index c3dd86c75ab..458fa0d606d 100644 --- a/packages/@n8n/telemetry/src/events/credentials.ts +++ b/packages/@n8n/telemetry/src/events/credentials.ts @@ -3,6 +3,32 @@ import { z } from 'zod/v4'; import { defineTelemetryEvents } from '../define'; export const CREDENTIALS_TELEMETRY = defineTelemetryEvents({ + USER_STARTED_CREDENTIAL_CONNECTION: { + name: 'User started credential connection', + description: + 'The user started a connection attempt or selected an existing credential in the setup panel. Includes retries.', + properties: z.object({ + source: z.literal('instance_ai_setup_panel'), + workflow_id: z.string(), + thread_id: z.string(), + credential_type: z.string(), + method: z.enum(['oauth', 'api_key', 'gateway', 'advanced', 'existing']), + }), + }, + USER_COMPLETED_CREDENTIAL_CONNECTION: { + name: 'User completed credential connection', + description: + 'A setup-panel credential was selected or connected and its workflow binding was applied or queued. This does not report workflow validation.', + properties: z.object({ + source: z.literal('instance_ai_setup_panel'), + workflow_id: z.string(), + thread_id: z.string(), + credential_type: z.string(), + credential_id: z.string().nullable(), + method: z.enum(['oauth', 'api_key', 'gateway', 'advanced', 'existing']), + binding_state: z.enum(['applied', 'noop', 'queued']), + }), + }, USER_PROBED_CREDENTIAL: { name: 'User probed credential', description: diff --git a/packages/@n8n/telemetry/src/events/instance-ai.ts b/packages/@n8n/telemetry/src/events/instance-ai.ts index 042f7640d63..20bb3a203c5 100644 --- a/packages/@n8n/telemetry/src/events/instance-ai.ts +++ b/packages/@n8n/telemetry/src/events/instance-ai.ts @@ -52,6 +52,47 @@ const threadActionSource = z.enum([ const prefillType = z.enum([...INSTANCE_AI_PREFILL_TYPES, INSTANCE_AI_PREFILL_TYPE_FALLBACK]); export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({ + SETUP_PANEL_STATE_OBSERVED: { + name: 'AI Assistant setup panel state observed', + description: + 'The setup panel observed a new requirement snapshot. Also fires when no setup is needed, including workflows whose credentials were already connected.', + properties: z.object({ + workflow_id: z.string(), + thread_id: z.string(), + credential_count: z.number(), + pending_credential_count: z.number(), + pending_parameter_count: z.number(), + already_connected_count: z.number(), + }), + }, + SETUP_PANEL_ITEM_SHOWN: { + name: 'AI Assistant setup panel item shown', + description: + 'A setup checklist row became visible. Reopening the same row within the mounted panel does not emit another event.', + properties: z.object({ + workflow_id: z.string(), + thread_id: z.string(), + kind: z.enum(['credential', 'parameters', 'details']), + credential_type: z.string().optional(), + parameter_count: z.number(), + }), + }, + SETUP_PANEL_DISMISSED: { + name: 'AI Assistant setup panel dismissed', + description: + 'A visible setup panel closed. The reason separates navigation and removed requirements from a finished execution or an explicit dismissal.', + properties: z.object({ + workflow_id: z.string(), + thread_id: z.string(), + reason: z.enum([ + 'navigation', + 'items_removed', + 'execution_succeeded', + 'execution_finished', + 'user_dismissed', + ]), + }), + }, USER_CLICKED_AI_CREDIT_BALANCE: { name: 'User clicked AI credit balance', description: diff --git a/packages/@n8n/telemetry/src/events/workflow.ts b/packages/@n8n/telemetry/src/events/workflow.ts index 2ffcd6ff074..3216b9c954b 100644 --- a/packages/@n8n/telemetry/src/events/workflow.ts +++ b/packages/@n8n/telemetry/src/events/workflow.ts @@ -3,6 +3,16 @@ import { z } from 'zod/v4'; import { defineTelemetryEvents } from '../define'; export const WORKFLOW_TELEMETRY = defineTelemetryEvents({ + USER_REQUESTED_WORKFLOW_TEST: { + name: 'User requested workflow test', + description: + 'The user started a manual workflow test from the AI Assistant setup panel. This event does not report execution success.', + properties: z.object({ + source: z.literal('instance_ai_setup_panel'), + workflow_id: z.string(), + thread_id: z.string().optional(), + }), + }, MULTIPLE_NODES_SELECTED: { name: 'User selected multiple nodes', description: diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.stories.ts new file mode 100644 index 00000000000..e2430753467 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.stories.ts @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; + +import N8nSetupConnection from './SetupConnection.vue'; + +const meta = { + title: 'Areas/Assistant/SetupConnection', + component: N8nSetupConnection, + args: { + connected: false, + actionLabel: 'Connect', + actions: [{ id: 'advanced', label: 'Advanced setup' }], + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Connect: Story = {}; +export const Connecting: Story = { args: { loading: true } }; +export const Connected: Story = { + args: { + connected: true, + valueLabel: 'Account', + value: 'demo@example.com', + actions: [ + { id: 'switch', label: 'Switch account' }, + { id: 'edit', label: 'Edit credential' }, + ], + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.test.ts new file mode 100644 index 00000000000..b672ce5c275 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.test.ts @@ -0,0 +1,62 @@ +import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/vue'; + +import SetupConnection from './SetupConnection.vue'; + +describe('SetupConnection', () => { + it('keeps Advanced setup available while required fields disable the primary action', async () => { + const { getByRole, getAllByRole, findByRole, emitted, rerender } = render(SetupConnection, { + props: { + connected: false, + actionLabel: 'Connect', + actionDisabled: true, + actions: [{ id: 'advanced', label: 'Advanced setup' }], + }, + }); + expect(getByRole('button', { name: 'Connect' })).toBeDisabled(); + expect(getAllByRole('button')).toEqual([ + getByRole('button', { name: 'More options' }), + getByRole('button', { name: 'Connect' }), + ]); + await userEvent.click(getByRole('button', { name: 'More options' })); + await userEvent.click(await findByRole('menuitem', { name: 'Advanced setup' })); + expect(emitted('select')).toEqual([['advanced']]); + expect(emitted('action')).toBeUndefined(); + await rerender({ actionDisabled: false }); + await userEvent.click(getByRole('button', { name: 'Connect' })); + expect(emitted('action')).toHaveLength(1); + }); + + it('shows a saved connection and opens its pencil menu without starting a connection', async () => { + const { getByRole, getByText, findByRole, queryByRole, emitted } = render(SetupConnection, { + props: { + connected: true, + value: 'user@example.com', + valueLabel: 'Account', + actionLabel: 'Connect', + actions: [{ id: 'replace', label: 'Switch account' }], + }, + }); + expect(getByText('user@example.com')).toBeVisible(); + expect(queryByRole('button', { name: 'Connect' })).toBeNull(); + await userEvent.click(getByRole('button', { name: 'Change connection' })); + await userEvent.click(await findByRole('menuitem', { name: 'Switch account' })); + expect(emitted('select')).toEqual([['replace']]); + expect(emitted('action')).toBeUndefined(); + }); + + it.each([false, true])( + 'disables connection controls during a request, connected: %s', + (connected) => { + const { getAllByRole } = render(SetupConnection, { + props: { + connected, + loading: true, + actionLabel: 'Connect', + actions: [{ id: 'edit', label: 'Edit' }], + }, + }); + for (const button of getAllByRole('button')) expect(button).toBeDisabled(); + }, + ); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.types.ts b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.types.ts new file mode 100644 index 00000000000..16af8a4ecf8 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.types.ts @@ -0,0 +1,23 @@ +import type { ButtonProps } from '../../types/button'; +import type { DropdownMenuItemProps } from '../N8nDropdownMenu/DropdownMenu.types'; + +export interface SetupConnectionProps { + /** Whether the host has a saved connection to display. */ + connected: boolean; + /** Label for the saved account or authentication value. */ + valueLabel?: string; + /** Public account identifier, credential name, or masked key. Never pass a secret. */ + value?: string; + /** Label for the primary connect or save action. */ + actionLabel: string; + /** Button treatment for the connect or save action. */ + actionVariant?: ButtonProps['variant']; + /** Secondary actions for the current connection method. */ + actions: DropdownMenuItemProps[]; + /** Whether the primary action is unavailable, such as for an incomplete form. */ + actionDisabled?: boolean; + /** Whether a connection attempt or save is in progress. */ + loading?: boolean; + /** Whether the connection and its secondary actions are unavailable. */ + disabled?: boolean; +} diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.vue b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.vue new file mode 100644 index 00000000000..dd20c63d946 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.stories.ts new file mode 100644 index 00000000000..ae56454a219 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.stories.ts @@ -0,0 +1,356 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'; +import { computed, ref, watch } from 'vue'; + +import N8nSelect from '../../v2/components/Select/Select.vue'; +import N8nButton from '../N8nButton'; +import N8nCopyInput from '../N8nCopyInput'; +import N8nIcon from '../N8nIcon'; +import N8nInput from '../N8nInput'; +import N8nSegmentControl from '../N8nSegmentControl'; +import N8nText from '../N8nText'; +import N8nSetupPanel from './SetupPanel.vue'; +import N8nSetupConnection from '../N8nSetupConnection/SetupConnection.vue'; + +const meta = { + title: 'Areas/Assistant/SetupPanel', + component: N8nSetupPanel, + parameters: { layout: 'centered' }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Checklist: Story = { + args: { + items: [ + { id: 'granola', title: 'Granola', completed: true }, + { id: 'slack', title: 'Slack', subtitle: 'Add connection, channel', completed: false }, + { id: 'details', title: 'Details', subtitle: 'Add summary length', completed: false }, + ], + }, + render: (args) => ({ + components: { N8nSetupPanel, N8nIcon, N8nInput }, + setup: () => ({ args, active: ref() }), + template: ` +
+ + + + + +
+ `, + }), +}; + +export const SetupComplete: Story = { + ...Checklist, + args: { + items: [ + { id: 'slack', title: 'Slack', completed: true }, + { id: 'details', title: 'Details', completed: true }, + ], + status: 'complete', + }, +}; + +export const Validating: Story = { + ...SetupComplete, + args: { ...SetupComplete.args, status: 'validating' }, +}; +export const Executing: Story = { + ...SetupComplete, + args: { ...SetupComplete.args, status: 'executing' }, +}; +export const ExecuteUnavailable: Story = { + ...SetupComplete, + args: { ...SetupComplete.args, executeDisabled: true }, +}; + +type Example = { + service: string; + method: 'oauth' | 'key' | 'selfHosted' | 'gateway' | 'details'; + connected?: boolean; + confirmed?: boolean; + field?: string; + custom?: boolean; + useOwnKey?: boolean; + /** Show the full completion flow instead of a static state example. */ + interactive?: boolean; +}; + +function example(state: Example): Story { + return { + args: { items: [] }, + render: () => ({ + components: { + N8nSetupPanel, + N8nSetupConnection, + N8nButton, + N8nCopyInput, + N8nInput, + N8nIcon, + N8nSegmentControl, + N8nText, + N8nSelect, + }, + setup() { + const active = ref( + state.method === 'oauth' && !state.connected ? undefined : 'service', + ); + const connected = ref(state.connected ?? false); + const confirmed = ref(state.confirmed ?? false); + const mode = ref(state.useOwnKey ? 'key' : 'credits'); + const key = ref(''); + const clientId = ref(''); + const clientSecret = ref(''); + const fieldValue = ref('suggested'); + const savedField = ref(state.confirmed ? 'suggested' : undefined); + const menuFeedback = ref(''); + const completed = computed( + () => + (state.method === 'details' || connected.value) && (!state.field || confirmed.value), + ); + const reducedMotion = usePreferredReducedMotion(); + const { start: returnToChecklist, stop: cancelReturn } = useTimeoutFn( + () => { + active.value = undefined; + }, + computed(() => (reducedMotion.value === 'reduce' ? 0 : 650)), + { immediate: false }, + ); + watch([active, fieldValue], cancelReturn); + function confirmField() { + confirmed.value = true; + savedField.value = fieldValue.value; + if (state.interactive) returnToChecklist(); + } + const items = computed(() => [ + { + id: 'service', + title: state.service, + completed: completed.value, + hasAction: state.method === 'oauth' && !connected.value, + subtitle: `Add ${!connected.value && state.method !== 'details' ? 'connection' : state.field}`, + }, + ]); + const actionLabel = computed(() => { + if (state.method === 'gateway') return mode.value === 'credits' ? 'Use credits' : 'Save'; + return state.method === 'selfHosted' + ? 'Save and sign in' + : state.method === 'key' + ? 'Save' + : 'Connect'; + }); + const actions = computed(() => { + if (!connected.value && state.method === 'gateway' && mode.value === 'credits') return []; + if (!connected.value) return [{ id: 'advanced', label: 'Advanced setup' }]; + if (state.method === 'gateway' && mode.value === 'credits') + return [ + { id: 'replace', label: 'Use my API key' }, + { id: 'manage', label: 'Manage Gateway credits' }, + ]; + if (state.custom) + return [ + { id: 'edit', label: 'Edit credential' }, + { id: 'switch', label: 'Connect with OAuth instead' }, + ]; + return [ + { + id: 'replace', + label: + state.method === 'key' || state.method === 'gateway' + ? 'Create new credential' + : 'Switch account', + }, + { id: 'edit', label: 'Edit credential' }, + ]; + }); + const value = computed(() => { + if (state.method === 'gateway' && mode.value === 'credits') return 'Gateway credits'; + if (state.custom || state.method === 'key' || state.method === 'gateway') + return '••••••••'; + return 'demo@example.com'; + }); + return { + state, + menuFeedback, + confirmField, + status: computed(() => + state.interactive + ? completed.value && savedField.value === fieldValue.value + ? 'complete' + : 'incomplete' + : undefined, + ), + active, + connected, + confirmed, + mode, + key, + clientId, + clientSecret, + fieldValue, + savedField, + items, + actions, + actionLabel, + value, + valueLabel: state.method === 'key' || state.method === 'gateway' ? 'API key' : 'Account', + actionDisabled: computed(() => + state.method === 'selfHosted' + ? !clientId.value || !clientSecret.value + : (state.method === 'key' || (state.method === 'gateway' && mode.value === 'key')) && + !key.value, + ), + onMenuAction: (id: string) => { + menuFeedback.value = actions.value.find((action) => action.id === id)?.label ?? ''; + if (id === 'replace' || id === 'switch') { + connected.value = false; + mode.value = 'key'; + } + }, + modeOptions: [ + { value: 'credits', label: 'Gateway credits' }, + { value: 'key', label: 'Use my API key' }, + ], + fieldOptions: [ + { + value: 'suggested', + label: state.field === 'channel' ? '#design-team' : 'Suggested value', + }, + { value: 'other', label: 'Another value' }, + ], + }; + }, + template: ` +
+ + + + + + +
+ `, + }), + }; +} + +export const ManagedOAuth = example({ service: 'Granola', method: 'oauth' }); +export const ManagedOAuthConnected = example({ + service: 'Granola', + method: 'oauth', + connected: true, +}); +export const AdvancedSetupConnected = example({ + service: 'Granola', + method: 'oauth', + connected: true, + custom: true, +}); +export const ManagedOAuthWithDetails = example({ + service: 'Slack', + method: 'oauth', + field: 'channel', +}); +export const ConnectedWithDetailsPending = example({ + service: 'Slack', + method: 'oauth', + connected: true, + field: 'channel', +}); +export const ServiceComplete = example({ + service: 'Slack', + method: 'oauth', + connected: true, + field: 'channel', + confirmed: true, +}); +export const ApiKey = example({ service: 'Clearbit', method: 'key' }); +export const ApiKeyConnected = example({ service: 'Clearbit', method: 'key', connected: true }); +export const GatewayCredits = example({ service: 'OpenAI', method: 'gateway' }); +export const StateTransitions = example({ + service: 'Example service', + method: 'gateway', + field: 'Notification channel', + interactive: true, +}); +export const GatewayOwnKey = example({ service: 'OpenAI', method: 'gateway', useOwnKey: true }); +export const GatewayCreditsConnected = example({ + service: 'OpenAI', + method: 'gateway', + connected: true, +}); +export const GatewayOwnKeyConnected = example({ + service: 'OpenAI', + method: 'gateway', + connected: true, + useOwnKey: true, +}); +export const SelfHostedOAuth = example({ + service: 'LinkedIn', + method: 'selfHosted', + field: 'Post as', +}); +export const SelfHostedDetailsPending = example({ + service: 'LinkedIn', + method: 'selfHosted', + connected: true, + field: 'Post as', +}); +export const SelfHostedComplete = example({ + service: 'LinkedIn', + method: 'selfHosted', + connected: true, + field: 'Post as', + confirmed: true, +}); +export const Details = example({ service: 'Details', method: 'details', field: 'Summary length' }); +export const DetailsComplete = example({ + service: 'Details', + method: 'details', + field: 'Summary length', + confirmed: true, +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.test.ts new file mode 100644 index 00000000000..08c27087ff2 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.test.ts @@ -0,0 +1,239 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, waitFor } from '@testing-library/vue'; +import { nextTick, ref } from 'vue'; + +import SetupPanel from './SetupPanel.vue'; + +describe('SetupPanel', () => { + it('replaces the service icon on completion without showing a progress count', async () => { + const items = [ + { id: 'slack', title: 'Slack', completed: false }, + { id: 'gmail', title: 'Gmail', completed: true }, + ]; + const { getByRole, queryByText, rerender } = render(SetupPanel, { + props: { items, activeItemId: 'slack' }, + slots: { icon: '' }, + }); + const detail = getByRole('dialog', { name: 'Slack' }); + expect(detail.querySelector('[data-test-id="service-icon"]')).toBeVisible(); + await rerender({ items: items.map((item) => ({ ...item, completed: true })) }); + expect(detail.querySelector('[data-test-id="service-icon"]')).toBeNull(); + expect(detail.querySelector('[data-icon="status-completed"]')).toBeVisible(); + await rerender({ activeItemId: undefined, status: 'complete' }); + expect(getByRole('button', { name: 'Setup complete' })).toBeVisible(); + expect(getByRole('button', { name: 'Execute' })).toBeVisible(); + expect(queryByText(/\d+\s*(?:of|\/)\s*\d+/i)).toBeNull(); + }); + + it('retains outgoing content while making the closing detail inaccessible', async () => { + const activeItemId = ref('slack'); + const { getByRole, getByTestId, queryByRole, unmount } = render( + { + components: { SetupPanel }, + setup: () => ({ activeItemId, items: [{ id: 'slack', title: 'Slack', completed: false }] }), + template: ` + + `, + }, + { global: { stubs: { transition: false } } }, + ); + const overlay = getByTestId('setup-panel-overlay'); + overlay.style.transitionDuration = '1s'; + overlay.style.transitionDelay = '0s'; + await fireEvent.click(getByRole('button', { name: 'Back to setup checklist' })); + await nextTick(); + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveTextContent('Connection form'); + expect(overlay).toHaveAttribute('inert'); + expect(overlay).toHaveAttribute('aria-hidden', 'true'); + expect(queryByRole('dialog')).toBeNull(); + unmount(); + }); + + it('shows a compact completion bar and lets users review completed items', async () => { + const { getByRole, queryByRole, emitted } = render(SetupPanel, { + props: { items: [{ id: 'slack', title: 'Slack', completed: true }], status: 'complete' }, + }); + expect(queryByRole('button', { name: 'Slack Complete' })).toBeNull(); + await userEvent.click(getByRole('button', { name: 'Setup complete' })); + expect(getByRole('button', { name: 'Slack Complete' })).toBeVisible(); + await userEvent.click(getByRole('button', { name: 'Execute' })); + expect(emitted('execute')).toHaveLength(1); + }); + + it.each(['validating', 'executing'] as const)('shows %s without an Execute action', (status) => { + const { getByRole, queryByRole } = render(SetupPanel, { + props: { items: [{ id: 'slack', title: 'Slack', completed: true }], status }, + }); + expect(getByRole('status')).toHaveTextContent( + status === 'validating' ? 'Validating…' : 'Executing…', + ); + expect(queryByRole('button', { name: 'Execute' })).toBeNull(); + }); + + it('opens the overlay by keyboard and returns focus to the selected row', async () => { + const activeItemId = ref(); + const { getByRole, getByText } = render({ + components: { SetupPanel }, + setup: () => ({ activeItemId, items: [{ id: 'slack', title: 'Slack', completed: false }] }), + template: ` + + `, + }); + getByRole('button', { name: 'Slack' }).focus(); + await userEvent.keyboard('{Enter}'); + expect(getByText('Connection form')).toBeVisible(); + const back = getByRole('button', { name: 'Back to setup checklist' }); + await waitFor(() => expect(back).toHaveFocus()); + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(getByRole('button', { name: 'Slack' })).toHaveFocus()); + }); + + it('keeps the checklist mounted but inaccessible while the overlay is open', async () => { + const activeItemId = ref(); + const { getByRole, getByTestId, queryByRole } = render({ + components: { SetupPanel }, + setup: () => ({ activeItemId, items: [{ id: 'slack', title: 'Slack', completed: false }] }), + template: ` + + `, + }); + await userEvent.click(getByRole('button', { name: 'Slack' })); + expect(getByRole('dialog', { name: 'Slack' })).toBeVisible(); + expect(getByTestId('setup-panel-row').closest('ul')).toHaveAttribute('inert'); + expect(queryByRole('button', { name: 'Slack' })).toBeNull(); + await userEvent.type(getByRole('textbox', { name: 'Channel' }), '#team'); + await userEvent.click(getByRole('textbox', { name: 'Chat' })); + expect(getByRole('textbox', { name: 'Channel' })).toHaveValue('#team'); + await userEvent.click(getByRole('textbox', { name: 'Channel' })); + await userEvent.keyboard('{Escape}'); + expect(queryByRole('dialog')).toBeNull(); + await waitFor(() => expect(getByRole('button', { name: 'Slack' })).toHaveFocus()); + }); + + it('renders a direct connection action without opening the overlay', async () => { + const connect = vi.fn(); + const { getByRole, queryByRole, emitted } = render(SetupPanel, { + props: { items: [{ id: 'slack', title: 'Slack', completed: false, hasAction: true }] }, + slots: { + action: { + template: '', + setup: () => ({ connect }), + }, + }, + }); + expect(getByRole('group', { name: 'Slack' })).toBeVisible(); + await userEvent.click(getByRole('button', { name: 'Connect' })); + expect(connect).toHaveBeenCalledOnce(); + expect(queryByRole('dialog')).toBeNull(); + expect(emitted('update:activeItemId')).toBeUndefined(); + }); + + it('exposes completion and prevents unavailable rows from opening', async () => { + const { getByRole, emitted } = render(SetupPanel, { + props: { + items: [ + { id: 'slack', title: 'Slack', completed: true }, + { id: 'details', title: 'Details', completed: false, disabled: true }, + ], + }, + }); + expect(getByRole('button', { name: 'Slack Complete' })).toBeEnabled(); + expect( + getByRole('button', { name: 'Slack Complete' }).querySelector( + '[data-icon="status-completed"]', + ), + ).toBeVisible(); + const details = getByRole('button', { name: 'Details' }); + expect(details).toBeDisabled(); + await userEvent.click(details); + expect(emitted('update:activeItemId')).toBeUndefined(); + }); + + it('returns to the remaining checklist when the active requirement disappears', async () => { + const { rerender, queryByText, getByRole, emitted } = render(SetupPanel, { + props: { items: [{ id: 'slack', title: 'Slack', completed: false }], activeItemId: 'slack' }, + slots: { detail: 'Connection form' }, + }); + await rerender({ items: [{ id: 'details', title: 'Details', completed: false }] }); + expect(queryByText('Connection form')).toBeNull(); + expect(getByRole('button', { name: 'Details' })).toBeVisible(); + await waitFor(() => expect(getByRole('button', { name: 'Details' })).toHaveFocus()); + expect(emitted('update:activeItemId')).toContainEqual([undefined]); + await rerender({ items: [] }); + expect(queryByText('Details')).toBeNull(); + }); + + it.each(['removed', 'disabled', 'empty'])( + 'emits one close event when the active item becomes %s', + async (change) => { + const items = [ + { id: 'slack', title: 'Slack', completed: false }, + { id: 'details', title: 'Details', completed: false }, + ]; + const { getByTestId, rerender, emitted } = render(SetupPanel, { + props: { items, activeItemId: 'slack' }, + global: { stubs: { transition: false } }, + }); + const overlay = getByTestId('setup-panel-overlay'); + overlay.style.transitionDuration = '0.05s'; + overlay.style.transitionDelay = '0s'; + await rerender({ + items: + change === 'empty' + ? [] + : change === 'removed' + ? items.slice(1) + : items.map((item) => ({ ...item, disabled: item.id === 'slack' })), + }); + const closeEventsBeforeLeave = emitted('detailClosed')?.length ?? 0; + await waitFor(() => expect(overlay).not.toBeInTheDocument()); + expect(emitted('detailClosed')).toEqual([[]]); + if (change !== 'empty') expect(closeEventsBeforeLeave).toBe(0); + }, + ); + + it.each(['missing', 'disabled', 'empty'])( + 'clears the %s initial selection without opening it after the items change', + async (initialState) => { + const activeItemId = ref('slack'); + const items = ref( + initialState === 'empty' + ? [] + : [ + { + id: initialState === 'missing' ? 'details' : 'slack', + title: 'Slack', + completed: false, + disabled: initialState === 'disabled', + }, + ], + ); + const onDetailClosed = vi.fn(); + const { queryByRole } = render({ + components: { SetupPanel }, + setup: () => ({ activeItemId, items, onDetailClosed }), + template: + '', + }); + expect(activeItemId.value).toBeUndefined(); + items.value = [{ id: 'slack', title: 'Slack', completed: false, disabled: false }]; + await nextTick(); + expect(queryByRole('dialog')).toBeNull(); + expect(onDetailClosed).not.toHaveBeenCalled(); + }, + ); + + it('does not open a selected detail when mounted during execution', () => { + const { queryByRole, getByRole, emitted } = render(SetupPanel, { + props: { + items: [{ id: 'slack', title: 'Slack', completed: true }], + activeItemId: 'slack', + status: 'executing', + }, + }); + expect(queryByRole('dialog')).toBeNull(); + expect(getByRole('status')).toHaveTextContent('Executing'); + expect(emitted('update:activeItemId')).toEqual([[undefined]]); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.types.ts b/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.types.ts new file mode 100644 index 00000000000..2c45cc510ac --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.types.ts @@ -0,0 +1,25 @@ +export interface SetupPanelItem { + /** Stable identity used to keep the detail open as items change. */ + id: string; + /** Service name or the label for a group of required fields. */ + title: string; + /** Short description of the values that remain to be configured. */ + subtitle?: string; + /** Whether all requirements represented by this row are configured. */ + completed: boolean; + /** Whether the host has enough context to open this item. */ + disabled?: boolean; + /** Whether the action slot replaces opening the detail card for this row. */ + hasAction?: boolean; +} + +export interface SetupPanelProps { + /** Checklist items in display order. */ + items: SetupPanelItem[]; + /** The item shown in the overlay, or undefined to show the checklist. */ + activeItemId?: string; + /** Current setup or test-execution state. Defaults to the checklist. */ + status?: 'incomplete' | 'validating' | 'complete' | 'executing'; + /** Whether another operation prevents starting a workflow test. */ + executeDisabled?: boolean; +} diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.vue b/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.vue new file mode 100644 index 00000000000..055eba299f3 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSetupPanel/SetupPanel.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/index.ts b/packages/frontend/@n8n/design-system/src/components/index.ts index 98e52809be6..ded2a567202 100644 --- a/packages/frontend/@n8n/design-system/src/components/index.ts +++ b/packages/frontend/@n8n/design-system/src/components/index.ts @@ -32,6 +32,10 @@ export { default as N8nAvatar } from './N8nAvatar'; export { default as N8nBadge } from './N8nBadge'; export { default as N8nBlockUi } from './N8nBlockUi'; export { default as N8nButton } from './N8nButton'; +export { default as N8nSetupPanel } from './N8nSetupPanel/SetupPanel.vue'; +export type { SetupPanelItem, SetupPanelProps } from './N8nSetupPanel/SetupPanel.types'; +export { default as N8nSetupConnection } from './N8nSetupConnection/SetupConnection.vue'; +export type { SetupConnectionProps } from './N8nSetupConnection/SetupConnection.types'; export { default as N8nButtonList } from './N8nButtonList'; export type { ButtonListOrientation, ButtonListProps } from './N8nButtonList'; export { default as N8nCallout } from './N8nCallout'; diff --git a/packages/frontend/@n8n/design-system/src/locale/lang/en.ts b/packages/frontend/@n8n/design-system/src/locale/lang/en.ts index 8dac5caf26d..810846159c1 100644 --- a/packages/frontend/@n8n/design-system/src/locale/lang/en.ts +++ b/packages/frontend/@n8n/design-system/src/locale/lang/en.ts @@ -2,6 +2,17 @@ import type { N8nLocale } from '../../types'; export default { + 'setupPanel.label': 'Workflow setup', + 'setupPanel.back': 'Back to setup checklist', + 'setupPanel.complete': 'Complete', + 'setupPanel.credentialSelected': 'Credential selected', + 'setupPanel.setupComplete': 'Setup complete', + 'setupPanel.execute': 'Execute', + 'setupPanel.validating': 'Validating…', + 'setupPanel.executing': 'Executing…', + 'setupPanel.connected': 'Connected', + 'setupPanel.changeConnection': 'Change connection', + 'setupPanel.moreOptions': 'More options', 'generic.retry': 'Retry', 'generic.cancel': 'Cancel', 'generic.close': 'Close', diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 39ce62e2e61..049e1d0358f 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -7102,10 +7102,42 @@ "instanceAi.artifactsPanel.context.agentPreviewNamed": "{name} session", "instanceAi.artifactsPanel.context.agentPreviewTitled": "{agentName} — {sessionTitle}", "instanceAi.artifactsPanel.context.agentPreviewSessionTitled": "{sessionTitle}", - "instanceAi.setupPanel.applyError": "Could not apply the changes.", + "instanceAi.setupPanel.applyError": "Changes couldn't be saved. Try again.", "instanceAi.setupPanel.connected": "Connected", - "instanceAi.setupPanel.executeMessage": "Run a test execution of this workflow.", + "instanceAi.setupPanel.connection": "connection", + "instanceAi.setupPanel.details": "Details", + "instanceAi.setupPanel.needs": "Add {items}", + "instanceAi.setupPanel.connect": "Connect", + "instanceAi.setupPanel.advancedSetup": "Advanced setup", + "instanceAi.setupPanel.switchAccount": "Switch account", + "instanceAi.setupPanel.connectWithOAuth": "Connect with OAuth instead", + "instanceAi.setupPanel.replaceKey": "Create new credential", + "instanceAi.setupPanel.editCredential": "Edit credential", + "instanceAi.setupPanel.useExisting": "Use an existing credential", + "instanceAi.setupPanel.account": "Account", + "instanceAi.setupPanel.apiKey": "API key", + "instanceAi.setupPanel.credential": "Credential", + "instanceAi.setupPanel.accessDescription": "This workflow needs a credential to access {service}.", + "instanceAi.setupPanel.helpFindApiKey": "Help me find my API key", + "instanceAi.setupPanel.helpFindAccessToken": "Help me find my access token", + "instanceAi.setupPanel.helpSetUp": "Help me set this up", + "instanceAi.setupPanel.helpVisibleFields": "The OAuth form asks for: {fields}.", + "instanceAi.setupPanel.helpManagedOAuthAvailable": "Managed OAuth is available on this instance.", + "instanceAi.setupPanel.helpManagedOAuthUnavailable": "Managed OAuth isn't available on this instance. Help me configure my own OAuth app.", + "instanceAi.setupPanel.setCredentialsPerNode": "Set credentials per node", + "instanceAi.setupPanel.creditsDescription": "Use Gateway credits without adding an API key.", + "instanceAi.setupPanel.connectedWith": "Connected with", + "instanceAi.setupPanel.useOwnKey": "Use my API key", + "instanceAi.setupPanel.useCredits": "Use credits", + "instanceAi.setupPanel.manageCredits": "Manage Gateway credits", + "instanceAi.setupPanel.saveAndSignIn": "Save and sign in", + "instanceAi.setupPanel.redirectUrl": "Add this redirect URL to your app", + "instanceAi.setupPanel.copyRedirectUrl": "Copy redirect URL", + "instanceAi.setupPanel.connectionError": "Connection failed. Try again.", + "instanceAi.setupPanel.executedMessage": "The workflow execution finished with execution id \"{executionId}\"", + "instanceAi.setupPanel.executeError": "The workflow test couldn't start. Try again.", "instanceAi.setupPanel.ready": "Ready", + "node.setupRequired": "Add a credential in the setup panel", "instanceAi.projectSelect.tooltip": "Where n8n Assistant creates automations", "instanceAi.projectSelect.placeholder": "Select a project", "instanceAi.projectSelect.search": "Search projects", diff --git a/packages/frontend/editor-ui/src/app/composables/useInstanceAiEditorCapability.ts b/packages/frontend/editor-ui/src/app/composables/useInstanceAiEditorCapability.ts index 7b9cff801f8..efa9e8bc32b 100644 --- a/packages/frontend/editor-ui/src/app/composables/useInstanceAiEditorCapability.ts +++ b/packages/frontend/editor-ui/src/app/composables/useInstanceAiEditorCapability.ts @@ -31,6 +31,8 @@ export interface InstanceAiCredentialContext { documentationUrl?: string; /** OAuth redirect/callback URL shown in the modal, when this is an OAuth credential. */ oauthRedirectUrl?: string; + /** Instance-specific setup guidance appended to the help question. */ + setupContext?: string; } /** diff --git a/packages/frontend/editor-ui/src/app/composables/useRunWorkflow.ts b/packages/frontend/editor-ui/src/app/composables/useRunWorkflow.ts index 63296738970..5542f72d91f 100644 --- a/packages/frontend/editor-ui/src/app/composables/useRunWorkflow.ts +++ b/packages/frontend/editor-ui/src/app/composables/useRunWorkflow.ts @@ -57,7 +57,6 @@ import { useExecutionsStore } from '@/features/execution/executions/executions.s import { useTelemetry } from '@n8n/composables/useTelemetry'; import { useSettingsStore } from '@n8n/stores/settings.store'; import { useUIStore } from '@/app/stores/ui.store'; -import { usePushConnectionStore } from '@/app/stores/pushConnection.store'; import { useNodeDirtiness } from '@/app/composables/useNodeDirtiness'; import { useCanvasOperations } from './useCanvasOperations'; import { chatEventBus } from '@n8n/chat/event-buses'; @@ -65,6 +64,7 @@ import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore'; import { useWorkflowSaving } from './useWorkflowSaving'; import { useDocumentTitle } from './useDocumentTitle'; import { useEditorContext } from './useEditorContext'; +import { useRunWorkflowApi } from './useRunWorkflowApi'; import { useChat } from '@n8n/chat/composables'; import type { WorkflowObjectAccessors } from '../types'; @@ -89,8 +89,8 @@ export function useRunWorkflow(useRunWorkflowOpts: { const agentRequestStore = useAgentRequestStore(); const rootStore = useRootStore(); - const pushConnectionStore = usePushConnectionStore(); const workflowsStore = useWorkflowsStore(); + const workflowRunner = useRunWorkflowApi(); const workflowDocumentStore = useRunWorkflowOpts.workflowDocumentStore ?? injectWorkflowDocumentStore(); const workflowExecutionState = computed(() => @@ -127,35 +127,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { // Starts to execute a workflow on server async function runWorkflowApi(runData: IStartRunData): Promise { - if (!pushConnectionStore.isConnected) { - // Do not start if the connection to server is not active - // because then it can not receive the data as it executes. - throw new Error(i18n.baseText('workflowRun.noActiveConnectionToTheServer')); - } - - // Set the execution as started, but still waiting for the execution to be retrieved - workflowExecutionState.value.setActiveExecutionId(null); - - let response: IExecutionPushResponse; - try { - response = await workflowsStore.runWorkflow(runData); - } catch (error) { - workflowExecutionState.value.setActiveExecutionId(undefined); - throw error; - } - - const workflowExecutionIdIsNew = - workflowExecutionState.value.previousExecutionId !== response.executionId; - const workflowExecutionIdIsPending = workflowExecutionState.value.activeExecutionId === null; - if (response.executionId && workflowExecutionIdIsNew && workflowExecutionIdIsPending) { - workflowExecutionState.value.setActiveExecutionId(response.executionId); - } - - if (response.waitingForWebhook === true) { - workflowExecutionState.value.setExecutionWaitingForWebhook(true); - } - - return response; + return await workflowRunner.runWorkflowApi(runData, workflowDocumentStore.value.documentId); } async function runWorkflow(options: { diff --git a/packages/frontend/editor-ui/src/app/composables/useRunWorkflowApi.ts b/packages/frontend/editor-ui/src/app/composables/useRunWorkflowApi.ts new file mode 100644 index 00000000000..4d9a485e345 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/composables/useRunWorkflowApi.ts @@ -0,0 +1,46 @@ +import { useI18n } from '@n8n/i18n'; +import type { IStartRunData } from '@/Interface'; +import { usePushConnectionStore } from '@/app/stores/pushConnection.store'; +import type { WorkflowDocumentId } from '@/app/stores/workflowDocument.store'; +import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; +import { useWorkflowsStore } from '@/app/stores/workflows.store'; +import type { IExecutionPushResponse } from '@/features/execution/executions/executions.types'; + +export function useRunWorkflowApi() { + const i18n = useI18n(); + const pushConnectionStore = usePushConnectionStore(); + const workflowsStore = useWorkflowsStore(); + + async function runWorkflowApi( + runData: IStartRunData, + documentId: WorkflowDocumentId, + ): Promise { + if (!pushConnectionStore.isConnected) { + throw new Error(i18n.baseText('workflowRun.noActiveConnectionToTheServer')); + } + + const executionState = useWorkflowExecutionStateStore(documentId); + // Push handlers only accept this run after the document starts tracking it. + executionState.setActiveExecutionId(null); + + let response: IExecutionPushResponse; + try { + response = await workflowsStore.runWorkflow(runData); + } catch (error) { + executionState.setActiveExecutionId(undefined); + throw error; + } + + if ( + response.executionId && + executionState.previousExecutionId !== response.executionId && + executionState.activeExecutionId === null + ) { + executionState.setActiveExecutionId(response.executionId); + } + if (response.waitingForWebhook === true) executionState.setExecutionWaitingForWebhook(true); + return response; + } + + return { runWorkflowApi }; +} diff --git a/packages/frontend/editor-ui/src/app/constants/durations.ts b/packages/frontend/editor-ui/src/app/constants/durations.ts index 2c021b94cc5..01b898fcad4 100644 --- a/packages/frontend/editor-ui/src/app/constants/durations.ts +++ b/packages/frontend/editor-ui/src/app/constants/durations.ts @@ -49,6 +49,9 @@ export const AGENT_EXTERNAL_UPDATE_NOTICE_DURATION = 5 * TIME.MINUTE; /** Keeps short agent tool and skill calls perceptible without adding continuous motion. */ export const AGENT_CAPABILITY_ACTIVE_MIN_DURATION = 300; +/** Keeps a completed setup item visible briefly before returning to the checklist. */ +export const SETUP_PANEL_SUCCESS_DELAY = 650; + /** Hover-intent delays for reveal-on-hover affordances (e.g. a collapsed group's description). */ export const HOVER_DELAY = { /** Delay before a hovered affordance reveals its content. */ diff --git a/packages/frontend/editor-ui/src/app/constants/injectionKeys.ts b/packages/frontend/editor-ui/src/app/constants/injectionKeys.ts index fc0ac3988ad..7908d352eaf 100644 --- a/packages/frontend/editor-ui/src/app/constants/injectionKeys.ts +++ b/packages/frontend/editor-ui/src/app/constants/injectionKeys.ts @@ -31,6 +31,8 @@ export const ExecutionDataStoreKey: InjectionKey< // derived from it via injectWorkflowExecutionStateStore(), so a subtree's // document scope and execution scope can never diverge. export const CanvasRenderDataKey: InjectionKey> = Symbol('CanvasRenderData'); +/** Keep setup hints compact while retaining their full text on the field. */ +export const CompactParameterHintsKey: InjectionKey = Symbol('CompactParameterHints'); /** * Opts resource-locator dropdowns into teleporting to ``. Defaults to * `false` (stay in the local stacking context, e.g. inside the NDV dialog). @@ -92,6 +94,8 @@ export type EditorEnabledFeatures = Partial> & { executionSuccessToasts?: boolean; executionErrorToasts?: boolean; executionButtonType?: 'primary' | 'secondary'; + /** Show missing credentials as setup warnings in hosts with a setup panel. */ + credentialSetupWarnings?: boolean; }; export const EditorEnabledFeaturesKey: InjectionKey>> = Symbol('EditorEnabledFeatures'); diff --git a/packages/frontend/editor-ui/src/app/constants/localStorage.ts b/packages/frontend/editor-ui/src/app/constants/localStorage.ts index a68772a7161..c91314b0c35 100644 --- a/packages/frontend/editor-ui/src/app/constants/localStorage.ts +++ b/packages/frontend/editor-ui/src/app/constants/localStorage.ts @@ -27,6 +27,16 @@ export const LOCAL_STORAGE_CHAT_HUB_HAD_CONVERSATION_BEFORE = (userId: string) = `${userId}_N8N_CHAT_HUB_HAD_CONVERSATION_BEFORE`; export const LOCAL_STORAGE_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN = (threadId: string) => `N8N_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN:${threadId}`; +export const LOCAL_STORAGE_INSTANCE_AI_SETUP_ITEMS = ( + userId: string, + threadId: string, + workflowId: string, +) => `N8N_INSTANCE_AI_SETUP_ITEMS:${userId}:${threadId}:${workflowId}`; +export const LOCAL_STORAGE_INSTANCE_AI_SETUP_DISMISSED = ( + userId: string, + threadId: string, + workflowId: string, +) => `N8N_INSTANCE_AI_SETUP_DISMISSED:${userId}:${threadId}:${workflowId}`; export const LOCAL_STORAGE_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO = 'N8N_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO'; export const LOCAL_STORAGE_SIDEBAR_WIDTH = 'N8N_SIDEBAR_WIDTH'; diff --git a/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts b/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts index 4ab1d7ca8df..e4836f2e2b0 100644 --- a/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts +++ b/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts @@ -91,6 +91,15 @@ export const MARKDOWN_NODE_TYPE = 'n8n-nodes-base.markdown'; export const XML_NODE_TYPE = 'n8n-nodes-base.xml'; export const CRYPTO_NODE_TYPE = 'n8n-nodes-base.crypto'; export const RSS_READ_NODE_TYPE = 'n8n-nodes-base.rssFeedRead'; + +// These nodes can use arbitrary providers. Gateway credentials require a known provider. +export const AI_GATEWAY_UNSUPPORTED_NODE_TYPES: readonly string[] = [ + 'n8n-nodes-base.httpRequest', + 'n8n-nodes-base.httpRequestTool', + '@n8n/n8n-nodes-langchain.toolHttpRequest', + 'n8n-nodes-base.graphql', + 'n8n-nodes-base.graphqlTool', +]; export const COMPRESSION_NODE_TYPE = 'n8n-nodes-base.compression'; export const EDIT_IMAGE_NODE_TYPE = 'n8n-nodes-base.editImage'; export const CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE = diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.test.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.test.ts index 93ca446f4ca..e87c01e0ceb 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.test.ts @@ -8,7 +8,7 @@ import { function renderMarkdown(content: string): string { const markdown = useChatHubMarkdownOptions('code-actions', 'table-container', null); - const renderer = new MarkdownIt(); + const renderer = new MarkdownIt(markdown.options); for (const plugin of markdown.plugins.value) { renderer.use(plugin); @@ -18,6 +18,12 @@ function renderMarkdown(content: string): string { } describe('useChatHubMarkdownOptions', () => { + it('makes plain URLs clickable while preserving code examples', () => { + const html = renderMarkdown('Open https://api.slack.com/apps or copy `https://example.test`.'); + expect(html).toContain('href="https://api.slack.com/apps" target="_blank" rel="noopener"'); + expect(html).toContain('https://example.test'); + }); + it('renders app-relative links without a new-tab target', () => { const html = renderMarkdown('[Preview](/projects/project-1/agents/agent-1/preview)'); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.ts index cd1a7e50892..2e612bfdd7d 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.ts @@ -82,6 +82,7 @@ export function useChatHubMarkdownOptions( const options = { breaks: true, + linkify: true, highlight(str: string, lang: string) { if (!lang) { return ''; // use external default escaping diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index 2fa87f8e838..8a1a7fcfac3 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -220,8 +220,7 @@ watch( { immediate: true }, ); // --- Setup panel (checklist docked above the composer) --- -// Anchors to the active canvas tab's workflow; on a hydrated thread with no -// tab state yet, the latest workflow artifact wins (insertion order). +// Early setup announcements can arrive before the first workflow artifact. const setupPanelWorkflowId = computed(() => { if (!settingsStore.isInstanceAiSetupPanelEnabled) return undefined; const active = preview.activeWorkflowId.value; @@ -230,13 +229,14 @@ const setupPanelWorkflowId = computed(() => { for (const entry of thread.producedArtifacts.values()) { if (entry.type === 'workflow') latest = entry.id; } - return latest; + return latest ?? thread.latestSetupWorkflowId; }); const setupPanelProjectId = computed(() => setupPanelWorkflowId.value ? thread.producedArtifacts.get(setupPanelWorkflowId.value)?.projectId : undefined, ); +const setupOverlapHeight = ref(0); const agentReturnContext = useAgentReturnContextStore().consumePendingArtifactReturn(); const agentReturnWorkflowId = agentReturnContext?.workflowId; @@ -809,6 +809,7 @@ async function persistTestAgentOfferDismissal(agentId: string) { > @@ -817,6 +818,7 @@ async function persistTestAgentOfferDismissal(agentId: string) { v-if="setupPanelWorkflowId" :workflow-id="setupPanelWorkflowId" :project-id="setupPanelProjectId" + @update:overlap-height="setupOverlapHeight = $event" />