feat(editor): Add response grouping and thinking UI for instance AI (no-changelog) (#28236)

Co-authored-by: Tuukka Kantola <tuukka@n8n.io>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Raúl Gómez Morales 2026-04-13 10:47:06 +02:00 committed by GitHub
parent a12d368482
commit 316d5bda80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 721 additions and 395 deletions

View file

@ -137,13 +137,17 @@ function ensureChildren(state: AgentRunState, agentId: string): string[] {
return children;
}
/** Append text to timeline — merges consecutive text entries. */
function appendTimelineText(timeline: InstanceAiTimelineEntry[], text: string): void {
/** Append text to timeline — merges consecutive text entries within the same responseId. */
function appendTimelineText(
timeline: InstanceAiTimelineEntry[],
text: string,
responseId?: string,
): void {
const last = timeline.at(-1);
if (last?.type === 'text') {
if (last?.type === 'text' && last.responseId === responseId) {
last.content += text;
} else {
timeline.push({ type: 'text', content: text });
timeline.push({ type: 'text', content: text, ...(responseId ? { responseId } : {}) });
}
}
@ -198,7 +202,11 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
const agent = ensureAgent(state, event.agentId);
if (agent) {
agent.textContent += event.payload.text;
appendTimelineText(ensureTimeline(state, event.agentId), event.payload.text);
appendTimelineText(
ensureTimeline(state, event.agentId),
event.payload.text,
event.responseId,
);
}
break;
}
@ -228,6 +236,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
ensureTimeline(state, event.agentId).push({
type: 'tool-call',
toolCallId: event.payload.toolCallId,
...(event.responseId ? { responseId: event.responseId } : {}),
});
}
break;
@ -278,9 +287,14 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
ensureChildren(state, event.agentId); // init empty
ensureTimeline(state, event.agentId); // init empty
ensureToolCallIds(state, event.agentId); // init empty
ensureTimeline(state, event.payload.parentId).push({
const parentTimeline = ensureTimeline(state, event.payload.parentId);
// Inherit responseId from the parent's last entry when not set on the event
// (agent-spawned events are emitted from tool code, not the stream executor).
const inheritedResponseId = event.responseId ?? parentTimeline.at(-1)?.responseId;
parentTimeline.push({
type: 'child',
agentId: event.agentId,
...(inheritedResponseId ? { responseId: inheritedResponseId } : {}),
});
}
break;
@ -358,13 +372,13 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
const agent = ensureAgent(state, event.agentId);
if (agent) {
agent.textContent += errorText;
appendTimelineText(ensureTimeline(state, event.agentId), errorText);
appendTimelineText(ensureTimeline(state, event.agentId), errorText, event.responseId);
} else {
// Fall back to root agent
const root = state.agentsById[state.rootAgentId];
if (root) {
root.textContent += errorText;
appendTimelineText(ensureTimeline(state, state.rootAgentId), errorText);
appendTimelineText(ensureTimeline(state, state.rootAgentId), errorText, event.responseId);
}
}
break;

View file

@ -466,7 +466,13 @@ export const threadTitleUpdatedPayloadSchema = z.object({
// Event schema (Zod discriminated union — single source of truth)
// ---------------------------------------------------------------------------
const eventBase = { runId: z.string(), agentId: z.string(), userId: z.string().optional() };
const eventBase = {
runId: z.string(),
agentId: z.string(),
userId: z.string().optional(),
/** Anthropic API response ID (msg_01...) — groups events from the same LLM response. */
responseId: z.string().optional(),
};
export const instanceAiEventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('run-start'), ...eventBase, payload: runStartPayloadSchema }),
@ -660,9 +666,9 @@ export interface InstanceAiToolCallState {
}
export type InstanceAiTimelineEntry =
| { type: 'text'; content: string }
| { type: 'tool-call'; toolCallId: string }
| { type: 'child'; agentId: string };
| { type: 'text'; content: string; responseId?: string }
| { type: 'tool-call'; toolCallId: string; responseId?: string }
| { type: 'child'; agentId: string; responseId?: string };
export interface InstanceAiAgentNode {
agentId: string;

View file

@ -224,13 +224,17 @@ Examples: search "credential" to find setup/test/delete tools, search "file" for
`
: ''
}## Safety
}## Communication Style
- **Be concise.** Ask for clarification when intent is ambiguous.
- **No emojis** only use emojis if the user explicitly requests it. Avoid emojis in all communication unless asked.
- **Always end with a text response.** The user cannot see raw tool output. After every tool call sequence, reply with a brief summary of what you found or did even if it's just one sentence. Never end your turn silently after tool calls.
## Safety
- **Destructive operations** show a confirmation UI automatically don't ask via text.
- **Credential setup** uses \`setup-workflow\` when a workflowId is available — it handles credentials, parameters, and triggers in one step. Use \`setup-credentials\` only when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow.
- **Never expose credential secrets** metadata only.
- **Be concise**. Ask for clarification when intent is ambiguous.
- **Always end with a text response.** The user cannot see raw tool output. After every tool call sequence, reply with a brief summary of what you found or did even if it's just one sentence. Never end your turn silently after tool calls.
${
researchMode

View file

@ -1818,6 +1818,8 @@ export async function executeResumableStream(
let activeMastraRunId = options.stream.runId ?? options.initialMastraRunId ?? '';
let text = options.stream.text;
let currentResponseId: string | undefined;
while (true) {
let suspension: SuspensionInfo | undefined;
let hasError = false;
@ -1853,6 +1855,14 @@ export async function executeResumableStream(
options.llmStepTraceHooks?.onStreamChunk(chunk);
// Always capture responseId from step-start, regardless of trace hook path.
if (isRecord(chunk) && chunk.type === 'step-start') {
const stepPayload = getChunkPayload(chunk);
const stepMessageId =
typeof stepPayload?.messageId === 'string' ? stepPayload.messageId : undefined;
currentResponseId = stepMessageId;
}
if (options.llmStepTraceHooks) {
// Step lifecycle is handled by prepareStep/onStepFinish callbacks.
} else if (isRecord(chunk) && chunk.type === 'step-start') {
@ -1898,7 +1908,12 @@ export async function executeResumableStream(
hasError = true;
}
const event = mapMastraChunkToEvent(options.context.runId, options.context.agentId, chunk);
const event = mapMastraChunkToEvent(
options.context.runId,
options.context.agentId,
chunk,
currentResponseId,
);
if (event) {
let shouldPublishEvent = true;

View file

@ -85,11 +85,13 @@ export function mapMastraChunkToEvent(
runId: string,
agentId: string,
chunk: unknown,
responseId?: string,
): InstanceAiEvent | null {
if (!isRecord(chunk)) return null;
const { type } = chunk;
const payload = isRecord(chunk.payload) ? chunk.payload : {};
const base = { runId, agentId, ...(responseId ? { responseId } : {}) };
// Mastra payload uses `text` (not `textDelta`) for text-delta chunks
const textValue =
@ -102,8 +104,7 @@ export function mapMastraChunkToEvent(
if (type === 'text-delta' && textValue !== undefined) {
return {
type: 'text-delta',
runId,
agentId,
...base,
payload: { text: textValue },
};
}
@ -111,8 +112,7 @@ export function mapMastraChunkToEvent(
if ((type === 'reasoning-delta' || type === 'reasoning') && textValue !== undefined) {
return {
type: 'reasoning-delta',
runId,
agentId,
...base,
payload: { text: textValue },
};
}
@ -120,8 +120,7 @@ export function mapMastraChunkToEvent(
if (type === 'tool-call') {
return {
type: 'tool-call',
runId,
agentId,
...base,
payload: {
toolCallId: typeof payload.toolCallId === 'string' ? payload.toolCallId : '',
toolName: typeof payload.toolName === 'string' ? payload.toolName : '',
@ -138,8 +137,7 @@ export function mapMastraChunkToEvent(
if (payload.isError === true) {
return {
type: 'tool-error',
runId,
agentId,
...base,
payload: {
toolCallId,
error: typeof payload.result === 'string' ? payload.result : 'Tool execution failed',
@ -149,8 +147,7 @@ export function mapMastraChunkToEvent(
return {
type: 'tool-result',
runId,
agentId,
...base,
payload: {
toolCallId,
result: payload.result,
@ -296,8 +293,7 @@ export function mapMastraChunkToEvent(
return {
type: 'confirmation-request',
runId,
agentId,
...base,
payload: {
requestId,
toolCallId,
@ -328,8 +324,7 @@ export function mapMastraChunkToEvent(
const errorInfo = extractErrorInfo(payload.error);
return {
type: 'error',
runId,
agentId,
...base,
payload: {
content: errorInfo.content,
...(errorInfo.statusCode !== undefined ? { statusCode: errorInfo.statusCode } : {}),

View file

@ -5283,7 +5283,7 @@
"instanceAi.planReview.description": "Review the plan, then approve it to start building or request changes to revise it.",
"instanceAi.planReview.feedbackPlaceholder": "Describe what should change before execution starts.",
"instanceAi.planReview.requestChanges": "Request changes",
"instanceAi.planReview.approve": "Approve and continue",
"instanceAi.planReview.approve": "Approve",
"instanceAi.planReview.approved": "Plan approved",
"instanceAi.message.retry": "Retry",
"instanceAi.input.amendPlaceholder": "Amend the {role} agent...",
@ -5387,6 +5387,10 @@
"instanceAi.stepTimeline.hideBrief": "Hide brief",
"instanceAi.stepTimeline.done": "Done",
"instanceAi.stepTimeline.craftingWorkflow": "Crafting workflow",
"instanceAi.activitySummary.toolCalls": "{count} tool calls",
"instanceAi.activitySummary.messages": "{count} messages",
"instanceAi.activitySummary.questions": "{count} answered questions",
"instanceAi.activitySummary.subagents": "{count} subagents",
"instanceAi.filesystem.label": "Local gateway",
"instanceAi.filesystem.connected": "Files connected",
"instanceAi.filesystem.connectWaiting": "Connecting...",

View file

@ -3,27 +3,15 @@ import { onMounted, onBeforeUnmount } from 'vue';
import BaseLayout from './BaseLayout.vue';
import AppSidebar from '@/app/components/app/AppSidebar.vue';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { useSidebarLayout } from '@/app/composables/useSidebarLayout';
const pushConnectionStore = usePushConnectionStore();
const { isCollapsed, toggleCollapse } = useSidebarLayout();
// Auto-collapse sidebar when entering instance-ai, restore on exit
let wasCollapsed = isCollapsed.value;
onMounted(() => {
pushConnectionStore.pushConnect();
wasCollapsed = isCollapsed.value;
if (!wasCollapsed) {
toggleCollapse();
}
});
onBeforeUnmount(() => {
pushConnectionStore.pushDisconnect();
if (isCollapsed.value !== wasCollapsed) {
toggleCollapse();
}
});
</script>

View file

@ -24,6 +24,7 @@ import {
IMPORT_WORKFLOW_URL_MODAL_KEY,
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
LOCAL_STORAGE_THEME,
LOCAL_STORAGE_SIDEBAR_WIDTH,
WHATS_NEW_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
@ -286,7 +287,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
write: (v) => String(v),
},
});
const sidebarWidth = useLocalStorage('N8N_SIDEBAR_WIDTH', 200);
const sidebarWidth = useLocalStorage(LOCAL_STORAGE_SIDEBAR_WIDTH, 200);
const currentView = ref<string>('');
const stateIsDirty = ref<boolean>(false);
// This tracks only structural changes without metadata (name or tags)

View file

@ -1,5 +1,8 @@
import type { InstanceAiAgentNode } from '@n8n/api-types';
/** Tool calls that are internal bookkeeping and should not be shown to the user. */
export const HIDDEN_TOOLS = new Set(['updateWorkingMemory']);
export interface ArtifactInfo {
type: 'workflow' | 'data-table';
resourceId: string;

View file

@ -3,9 +3,16 @@ import type { InstanceAiAgentNode } from '@n8n/api-types';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useElementHover } from '@vueuse/core';
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { computed, useTemplateRef } from 'vue';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed, toRef, useTemplateRef } from 'vue';
import type { ArtifactInfo } from '../agentTimeline.utils';
import { useInstanceAiStore } from '../instanceAi.store';
import { useTimelineGrouping } from '../useTimelineGrouping';
import AgentTimeline from './AgentTimeline.vue';
import ArtifactCard from './ArtifactCard.vue';
import InstanceAiMarkdown from './InstanceAiMarkdown.vue';
import ResponseGroup from './ResponseGroup.vue';
const props = withDefaults(
defineProps<{
@ -18,10 +25,32 @@ const props = withDefaults(
);
const i18n = useI18n();
const store = useInstanceAiStore();
const hasReasoning = computed(() => props.agentNode.reasoning.length > 0);
const triggerRef = useTemplateRef<HTMLElement>('triggerRef');
const isHovered = useElementHover(triggerRef);
const segments = useTimelineGrouping(toRef(props, 'agentNode'));
/** Whether to show grouped/collapsed view (root + grouping available). */
const showGrouped = computed(() => props.isRoot && segments.value !== null);
/** Index of the last response-group segment (for isLast prop). */
const lastGroupIdx = computed(() => {
if (!segments.value) return -1;
for (let i = segments.value.length - 1; i >= 0; i--) {
if (segments.value[i].kind === 'response-group') return i;
}
return -1;
});
function resolveArtifactName(artifact: ArtifactInfo): string {
for (const entry of store.resourceRegistry.values()) {
if (entry.id === artifact.resourceId) return entry.name;
}
return artifact.name;
}
</script>
<template>
@ -40,18 +69,47 @@ const isHovered = useElementHover(triggerRef);
{{ i18n.baseText('instanceAi.message.reasoning') }}
</N8nButton>
</CollapsibleTrigger>
<CollapsibleContent>
<AnimatedCollapsibleContent>
<N8nText tag="div" :class="$style.reasoningContent">{{ props.agentNode.reasoning }}</N8nText>
</CollapsibleContent>
</AnimatedCollapsibleContent>
</CollapsibleRoot>
<!-- Unified timeline renderer -->
<AgentTimeline :agent-node="props.agentNode" />
<!-- Completed with responseId grouping: collapsed response groups + artifacts + trailing text -->
<template v-if="showGrouped">
<template v-for="(segment, idx) in segments" :key="idx">
<ResponseGroup
v-if="segment.kind === 'response-group'"
:group="segment"
:agent-node="props.agentNode"
:is-last="idx === lastGroupIdx"
/>
<!-- Artifacts from child agents in this group, rendered in-place after the group -->
<template v-if="segment.kind === 'response-group' && segment.artifacts.length > 0">
<ArtifactCard
v-for="artifact in segment.artifacts"
:key="artifact.resourceId"
:type="artifact.type"
:name="resolveArtifactName(artifact)"
:resource-id="artifact.resourceId"
:project-id="artifact.projectId"
/>
</template>
<!-- Trailing text (the actual answer) always visible -->
<N8nText v-if="segment.kind === 'trailing-text'" size="large">
<InstanceAiMarkdown :content="segment.content" />
</N8nText>
</template>
</template>
<!-- Active / no grouping available: show timeline directly -->
<AgentTimeline v-else :agent-node="props.agentNode" />
</template>
<style lang="scss" module>
.reasoningTrigger {
color: var(--color--text--tint-2);
color: var(--text-color--subtler);
}
.reasoningContent {

View file

@ -1,7 +1,8 @@
<script lang="ts" setup>
import type { InstanceAiAgentNode } from '@n8n/api-types';
import { N8nCallout, N8nIcon } from '@n8n/design-system';
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed, ref, watch } from 'vue';
import SubagentStepTimeline from './SubagentStepTimeline.vue';
import TimelineStepButton from './TimelineStepButton.vue';
@ -10,9 +11,9 @@ const props = defineProps<{
agentNode: InstanceAiAgentNode;
}>();
const isExpanded = ref(false);
const isActive = computed(() => props.agentNode.status === 'active');
const isExpanded = ref(isActive.value); // Start expanded if active, otherwise collapsed
const isError = computed(() => props.agentNode.status === 'error');
const sectionTitle = computed(
@ -48,9 +49,9 @@ watch(
<span :class="{ [$style.shimmer]: isActive }">{{ sectionTitle }}</span>
</TimelineStepButton>
</CollapsibleTrigger>
<CollapsibleContent :class="$style.content">
<AnimatedCollapsibleContent :class="$style.content">
<SubagentStepTimeline :agent-node="props.agentNode" />
</CollapsibleContent>
</AnimatedCollapsibleContent>
</CollapsibleRoot>
<!-- Error display -->
<N8nCallout v-if="isError && props.agentNode.error" theme="danger">

View file

@ -1,9 +1,14 @@
<script lang="ts" setup>
import type { InstanceAiAgentNode, InstanceAiToolCallState, TaskList } from '@n8n/api-types';
import type {
InstanceAiAgentNode,
InstanceAiTimelineEntry,
InstanceAiToolCallState,
TaskList,
} from '@n8n/api-types';
import { N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { extractArtifacts, type ArtifactInfo } from '../agentTimeline.utils';
import { extractArtifacts, HIDDEN_TOOLS, type ArtifactInfo } from '../agentTimeline.utils';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useInstanceAiStore } from '../instanceAi.store';
@ -77,19 +82,21 @@ const props = withDefaults(
defineProps<{
agentNode: InstanceAiAgentNode;
compact?: boolean;
/** When provided, renders only these entries instead of the full timeline. */
visibleEntries?: InstanceAiTimelineEntry[];
}>(),
{
compact: false,
visibleEntries: undefined,
},
);
const timelineEntries = computed(() => props.visibleEntries ?? props.agentNode.timeline);
defineSlots<{
'after-tool-call'?: (props: { toolCall: InstanceAiToolCallState }) => unknown;
}>();
/** Tool calls that are internal bookkeeping and should not be shown to the user. */
const HIDDEN_TOOLS = new Set(['updateWorkingMemory']);
/** Index tool calls by ID for O(1) lookup and proper reactivity tracking. */
const toolCallsById = computed(() => {
const map: Record<string, InstanceAiToolCallState> = {};
@ -167,7 +174,7 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi
<template>
<div :class="$style.timeline">
<template v-for="(entry, idx) in props.agentNode.timeline" :key="idx">
<template v-for="(entry, idx) in timelineEntries" :key="idx">
<!-- Text segment -->
<N8nText v-if="entry.type === 'text'" size="large" :compact="props.compact">
<InstanceAiMarkdown :content="entry.content" />
@ -258,16 +265,18 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi
"
/>
<!-- Artifact cards for completed subagents (one per workflow/data-table) -->
<ArtifactCard
v-for="artifact in extractArtifacts(childrenById[entry.agentId])"
:key="artifact.resourceId"
:type="artifact.type"
:name="resolveArtifactName(artifact)"
:resource-id="artifact.resourceId"
:project-id="artifact.projectId"
:metadata="formatArtifactMetadata(artifact)"
/>
<!-- Artifact cards for completed subagents (skip when inside grouped view) -->
<template v-if="!props.visibleEntries">
<ArtifactCard
v-for="artifact in extractArtifacts(childrenById[entry.agentId])"
:key="artifact.resourceId"
:type="artifact.type"
:name="resolveArtifactName(artifact)"
:resource-id="artifact.resourceId"
:project-id="artifact.projectId"
:metadata="formatArtifactMetadata(artifact)"
/>
</template>
</template>
</template>
</div>

View file

@ -0,0 +1,41 @@
<script lang="ts" setup>
import { CollapsibleContent } from 'reka-ui';
</script>
<template>
<CollapsibleContent :class="$style.content">
<slot />
</CollapsibleContent>
</template>
<style lang="scss" module>
.content {
overflow: hidden;
&[data-state='open'] {
animation: slideDown 0.2s ease-out;
}
&[data-state='closed'] {
animation: slideUp 0.2s ease-out;
}
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--reka-collapsible-content-height);
}
}
@keyframes slideUp {
from {
height: var(--reka-collapsible-content-height);
}
to {
height: 0;
}
}
</style>

View file

@ -6,7 +6,7 @@
* after the user submits answers via the questions wizard.
* Styled to match the AI builder's UserAnswersMessage.
*/
import { N8nCard, N8nText } from '@n8n/design-system';
import { N8nText } from '@n8n/design-system';
import type { InstanceAiToolCallState } from '@n8n/api-types';
const props = defineProps<{
@ -48,18 +48,36 @@ function getAnswers(): DisplayAnswer[] {
</script>
<template>
<N8nCard data-test-id="instance-ai-answered-questions">
<div v-for="(item, idx) in getAnswers()" :key="idx" :class="$style.answerItem">
<N8nText :bold="true" size="large" :class="$style.question">
{{ item.question }}
</N8nText>
<N8nText v-if="item.skipped" :class="$style.skipped" size="large">Skipped</N8nText>
<N8nText v-else size="large">{{ item.answer }}</N8nText>
<div :class="$style.wrapper">
<div :class="$style.userBubble" data-test-id="instance-ai-answered-questions">
<div v-for="(item, idx) in getAnswers()" :key="idx" :class="$style.answerItem">
<N8nText :bold="true" size="large" :class="$style.question">
{{ item.question }}
</N8nText>
<N8nText v-if="item.skipped" :class="$style.skipped" size="large">Skipped</N8nText>
<N8nText v-else size="large">{{ item.answer }}</N8nText>
</div>
</div>
</N8nCard>
</div>
</template>
<style lang="scss" module>
.wrapper {
/* Break out of the 90%-wide .message container to align with user bubbles */
width: calc(100% / 0.9);
display: flex;
justify-content: flex-end;
margin-bottom: var(--spacing--xs);
}
.userBubble {
background: var(--color--background);
padding: var(--spacing--xs) var(--spacing--sm) var(--spacing--sm);
border-radius: var(--radius--xl);
white-space: pre-wrap;
word-break: break-word;
}
.answerItem {
display: flex;
flex-direction: column;

View file

@ -46,7 +46,7 @@ function handleClick(e: MouseEvent) {
</script>
<template>
<N8nCard hoverable :class="$style.card" @click="handleClick">
<N8nCard :class="$style.card" @click="handleClick">
<template #prepend>
<N8nIcon :icon="icon" size="large" :class="$style.icon" />
</template>
@ -60,6 +60,12 @@ function handleClick(e: MouseEvent) {
<style lang="scss" module>
.card {
cursor: pointer;
background-color: var(--color--background--light-3);
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: var(--shadow--card-hover);
}
}
.icon {
@ -77,7 +83,7 @@ function handleClick(e: MouseEvent) {
}
.metadata {
color: var(--color--text--tint-2);
color: var(--text-color--subtler);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View file

@ -18,6 +18,8 @@
:global(.n8n-icon) {
flex-shrink: 0;
align-self: baseline;
margin-top: var(--spacing--2xs);
}
}
</style>

View file

@ -11,7 +11,7 @@
color: var(--color--text);
word-break: break-all;
padding: var(--spacing--2xs);
background: var(--color--background);
background: light-dark(var(--color--background), var(--color--neutral-850));
border-radius: var(--radius);
border: var(--border);
}

View file

@ -6,7 +6,7 @@
<style lang="scss" module>
.dataSection {
font-size: var(--font-size--2xs);
font-size: var(--font-size--sm);
color: var(--color--text--tint-2);
background: var(--color--foreground--tint-2);
border-radius: var(--radius);

View file

@ -1,7 +1,8 @@
<script lang="ts" setup>
import { N8nBadge, N8nCard, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed } from 'vue';
import { useToolLabel } from '../toolLabels';
import TimelineStepButton from './TimelineStepButton.vue';
@ -47,7 +48,7 @@ const briefing = computed(() => {
<N8nText bold>{{ role }}</N8nText>
</TimelineStepButton>
</CollapsibleTrigger>
<CollapsibleContent>
<AnimatedCollapsibleContent>
<N8nCard>
<N8nText bold>{{ i18n.baseText('instanceAi.delegateCard.tools') }}</N8nText>
<div v-if="tools.length" :class="$style.toolsRow">
@ -55,7 +56,7 @@ const briefing = computed(() => {
</div>
<N8nText tag="div">{{ briefing }}</N8nText>
</N8nCard>
</CollapsibleContent>
</AnimatedCollapsibleContent>
</CollapsibleRoot>
<!-- Result is intentionally NOT shown here.
The sub-agent's full activity (tool calls + text) is rendered by

View file

@ -86,7 +86,7 @@ function onDropdownSelect(action: string) {
<ConfirmationFooter>
<N8nButton
variant="outline"
size="small"
size="medium"
:label="i18n.baseText('instanceAi.domainAccess.deny')"
data-test-id="domain-access-deny"
@click="handleAction(false)"

View file

@ -109,7 +109,7 @@ async function confirm(decision: string) {
v-for="opt in otherOptions"
:key="opt.decision"
variant="outline"
size="small"
size="medium"
:label="opt.label"
@click="confirm(opt.decision)"
/>
@ -147,6 +147,7 @@ async function confirm(decision: string) {
.root {
border: var(--border);
border-radius: var(--radius--lg);
background-color: var(--color--background--light-3);
}
.body {

View file

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { N8nIcon } from '@n8n/design-system';
import { N8nHeading, N8nIcon } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useInstanceAiStore } from '../instanceAi.store';
import type { TaskItem } from '@n8n/api-types';
@ -73,9 +73,9 @@ const artifactIconMap: Record<string, IconName> = {
<div :class="$style.panel">
<!-- Artifacts section -->
<div :class="[$style.section, $style.card]">
<div :class="$style.sectionTitle">
<N8nHeading :class="$style.sectionTitle" tag="h3" size="small" bold>
{{ i18n.baseText('instanceAi.artifactsPanel.title') }}
</div>
</N8nHeading>
<div v-if="artifacts.length > 0" :class="$style.artifactList">
<div
@ -106,10 +106,10 @@ const artifactIconMap: Record<string, IconName> = {
<!-- Tasks section -->
<div v-if="tasks" :class="[$style.section, $style.card]">
<div :class="$style.sectionTitle">
<N8nHeading :class="$style.sectionTitle" tag="h3" size="small" bold>
{{ i18n.baseText('instanceAi.artifactsPanel.tasks') }}
<span :class="$style.progress">{{ doneCount }}/{{ tasks.tasks.length }}</span>
</div>
</N8nHeading>
<div :class="$style.taskList">
<div
@ -161,16 +161,14 @@ const artifactIconMap: Record<string, IconName> = {
}
.sectionTitle {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
margin-bottom: var(--spacing--2xs);
display: flex;
align-items: center;
align-items: baseline;
gap: var(--spacing--3xs);
}
.progress {
font-size: var(--font-size--3xs);
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
font-weight: var(--font-weight--regular);
}
@ -185,7 +183,7 @@ const artifactIconMap: Record<string, IconName> = {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--2xs) var(--spacing--2xs);
padding: var(--spacing--2xs) 0;
cursor: pointer;
border-radius: var(--radius);
transition: background-color 0.2s ease;
@ -214,12 +212,12 @@ const artifactIconMap: Record<string, IconName> = {
}
.artifactIcon {
color: var(--color--text--tint-1);
color: var(--color--text);
}
.artifactName {
font-size: var(--font-size--sm);
color: var(--color--text);
color: var(--color--text--shade-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View file

@ -370,14 +370,14 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
/>
<N8nButton
v-if="!(textInputValues[chunk.item.toolCall.confirmation.requestId] ?? '').trim()"
size="small"
size="medium"
variant="outline"
@click="handleTextSkip(chunk.item.toolCall.confirmation)"
>
{{ i18n.baseText('instanceAi.askUser.skip') }}
</N8nButton>
<N8nButton
size="small"
size="medium"
variant="solid"
:disabled="
!(textInputValues[chunk.item.toolCall.confirmation.requestId] ?? '').trim()
@ -424,7 +424,7 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
</N8nText>
<N8nButton
data-test-id="instance-ai-panel-confirm-approve-all"
size="small"
size="medium"
variant="subtle"
@click="handleApproveAll(chunk.items)"
>
@ -464,7 +464,7 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
<ConfirmationFooter>
<N8nButton
data-test-id="instance-ai-panel-confirm-deny"
size="small"
size="medium"
variant="outline"
@click="handleConfirm(item, false)"
>
@ -477,7 +477,7 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
: 'solid'
"
data-test-id="instance-ai-panel-confirm-approve"
size="small"
size="medium"
@click="handleConfirm(item, true)"
>
{{ i18n.baseText('instanceAi.confirmation.approve') }}
@ -501,6 +501,7 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
.root {
border: var(--border);
border-radius: var(--radius--lg);
background-color: var(--color--background--light-3);
}
.items {
@ -548,7 +549,7 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
}
.textCard {
background-color: transparent;
background-color: var(--color--background--light-3);
}
</style>

View file

@ -306,11 +306,7 @@ async function handleLater() {
<template>
<div>
<template v-if="!isSubmitted">
<div
v-if="currentRequest"
data-test-id="instance-ai-credential-card"
:class="[$style.card, { [$style.completed]: allSelected }]"
>
<div v-if="currentRequest" data-test-id="instance-ai-credential-card" :class="$style.card">
<!-- Header -->
<header :class="$style.header">
<CredentialIcon :credential-type-name="currentRequest.credentialType" :size="16" />
@ -361,8 +357,8 @@ async function handleLater() {
<div :class="$style.footerNav">
<N8nButton
v-if="showArrows"
variant="outline"
size="xsmall"
variant="ghost"
size="medium"
icon-only
:disabled="isPrevDisabled"
data-test-id="instance-ai-credential-prev"
@ -376,8 +372,8 @@ async function handleLater() {
</N8nText>
<N8nButton
v-if="showArrows"
variant="outline"
size="xsmall"
variant="ghost"
size="medium"
icon-only
:disabled="isNextDisabled"
data-test-id="instance-ai-credential-next"
@ -391,7 +387,7 @@ async function handleLater() {
<div :class="$style.footerActions">
<N8nButton
variant="outline"
size="small"
size="medium"
:class="$style.actionButton"
:label="
i18n.baseText(
@ -404,7 +400,7 @@ async function handleLater() {
/>
<N8nButton
size="small"
size="medium"
:class="$style.actionButton"
:label="i18n.baseText('instanceAi.credential.continueButton')"
:disabled="!anySelected"
@ -444,10 +440,7 @@ async function handleLater() {
padding: 0;
border: var(--border);
border-radius: var(--radius);
&.completed {
border-color: var(--color--success);
}
background-color: var(--color--background--light-3);
}
.header {

View file

@ -1,7 +1,7 @@
<script lang="ts" setup>
import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChunk.vue';
import type { ComponentPublicInstance } from 'vue';
import { computed, inject, onMounted, onUpdated, ref, useCssModule } from 'vue';
import { computed, inject, onBeforeUnmount, onMounted, onUpdated, ref, useCssModule } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store';
const props = defineProps<{
@ -124,6 +124,9 @@ function applyResourceChip(link: HTMLAnchorElement, type: string): void {
}
}
/** Track click handlers attached to links so they can be cleaned up. */
const linkHandlers = new WeakMap<HTMLAnchorElement, (e: MouseEvent) => void>();
/**
* Post-process the rendered DOM to transform resource links into
* styled resource chips with icons. Handles both:
@ -162,7 +165,7 @@ function enhanceResourceLinks(): void {
applyResourceChip(link, type);
// Regular click opens preview; Cmd/Ctrl+click falls through to default (new tab)
link.addEventListener('click', (e: MouseEvent) => {
const handler = (e: MouseEvent) => {
if (e.metaKey || e.ctrlKey) return; // Let browser handle new-tab
const canPreview =
@ -177,7 +180,9 @@ function enhanceResourceLinks(): void {
} else if (type === 'data-table' && registryEntry?.projectId) {
openDataTablePreview?.(id, registryEntry.projectId);
}
});
};
link.addEventListener('click', handler);
linkHandlers.set(link, handler);
continue;
}
@ -193,8 +198,25 @@ function enhanceResourceLinks(): void {
}
}
/** Remove click handlers from all enhanced links. */
function cleanupLinkHandlers(): void {
if (!wrapperRef.value) return;
const allLinks = (wrapperRef.value.$el as HTMLElement).querySelectorAll<HTMLAnchorElement>('a');
for (const link of allLinks) {
const handler = linkHandlers.get(link);
if (handler) {
link.removeEventListener('click', handler);
linkHandlers.delete(link);
}
}
}
onMounted(enhanceResourceLinks);
onUpdated(enhanceResourceLinks);
onUpdated(() => {
cleanupLinkHandlers();
enhanceResourceLinks();
});
onBeforeUnmount(cleanupLinkHandlers);
</script>
<template>

View file

@ -388,10 +388,6 @@ function onOptionMouseEnter(idx: number) {
tabindex="0"
@keydown="onKeydown"
>
<p v-if="introMessage" :class="$style.intro">
{{ introMessage }}
</p>
<Transition :name="$style.questionFade" mode="out-in">
<div :key="currentQuestion.id" :class="$style.question">
<N8nText tag="p" :bold="true" :class="$style.questionText">
@ -514,7 +510,7 @@ function onOptionMouseEnter(idx: number) {
<div :class="$style.pagination">
<N8nButton
variant="ghost"
size="xsmall"
size="medium"
icon-only
:disabled="isFirstQuestion"
data-test-id="instance-ai-questions-back"
@ -530,7 +526,7 @@ function onOptionMouseEnter(idx: number) {
</N8nText>
<N8nButton
variant="ghost"
size="xsmall"
size="medium"
icon-only
:disabled="isLastQuestion"
data-test-id="instance-ai-questions-forward"
@ -545,7 +541,7 @@ function onOptionMouseEnter(idx: number) {
<N8nButton
v-if="showSkipButton"
variant="outline"
size="small"
size="medium"
:disabled="disabled"
data-test-id="instance-ai-questions-skip"
@click="skipQuestion"
@ -556,7 +552,7 @@ function onOptionMouseEnter(idx: number) {
<N8nButton
v-if="showNextButton"
:variant="isNextEnabled ? 'solid' : 'outline'"
size="small"
size="medium"
:disabled="disabled || isSubmitted || !isNextEnabled"
data-test-id="instance-ai-questions-next"
@click="goToNext"
@ -588,6 +584,7 @@ function onOptionMouseEnter(idx: number) {
outline: none;
border: var(--border);
border-radius: var(--radius--lg);
background-color: var(--color--background--light-3);
}
.question {
@ -619,7 +616,7 @@ function onOptionMouseEnter(idx: number) {
&:hover,
&.highlighted {
background-color: light-dark(var(--color--neutral-200), var(--color--neutral-800));
background-color: light-dark(var(--color--neutral-100), var(--color--neutral-800));
}
&:hover .arrowIndicator,
@ -691,7 +688,7 @@ function onOptionMouseEnter(idx: number) {
&:hover,
&.highlighted {
background-color: light-dark(var(--color--neutral-200), var(--color--neutral-800));
background-color: light-dark(var(--color--neutral-100), var(--color--neutral-800));
}
}
@ -707,7 +704,7 @@ function onOptionMouseEnter(idx: number) {
&:hover,
&.highlighted {
background-color: light-dark(var(--color--neutral-200), var(--color--neutral-800));
background-color: light-dark(var(--color--neutral-100), var(--color--neutral-800));
}
.somethingElseInput {
@ -739,7 +736,7 @@ function onOptionMouseEnter(idx: number) {
&:hover,
&.highlighted {
background-color: light-dark(var(--color--neutral-200), var(--color--neutral-800));
background-color: light-dark(var(--color--neutral-100), var(--color--neutral-800));
}
}

View file

@ -222,7 +222,7 @@ function handleThreadAction(action: string, threadId: string) {
.threadList {
flex: 1;
overflow-y: auto;
padding: var(--spacing--4xs);
padding: var(--spacing--2xs);
}
.group {
@ -242,6 +242,7 @@ function handleThreadAction(action: string, threadId: string) {
.threadItem {
display: flex;
align-items: center;
height: 32px;
border-radius: var(--radius);
transition: background-color 0.1s ease;
@ -262,7 +263,8 @@ function handleThreadAction(action: string, threadId: string) {
gap: var(--spacing--3xs);
flex: 1;
min-width: 0;
padding: var(--spacing--2xs) var(--spacing--xs);
height: 100%;
padding: 0 var(--spacing--xs);
color: var(--color--text) !important;
text-decoration: none !important;
outline: none;
@ -282,7 +284,7 @@ function handleThreadAction(action: string, threadId: string) {
}
.threadLinkActive {
background-color: var(--color--background--light-1);
// Active background handled by .threadItem.active
}
.threadIcon {

View file

@ -2,7 +2,8 @@
import type { InstanceAiToolCallState } from '@n8n/api-types';
import { N8nIcon } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed, ref } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store';
import { useToolLabel } from '../toolLabels';
@ -74,7 +75,7 @@ const resolvedAction = computed((): 'approved' | 'denied' | 'deferred' | null =>
</div>
<N8nIcon :icon="isOpen ? 'chevron-up' : 'chevron-down'" size="small" />
</CollapsibleTrigger>
<CollapsibleContent :class="$style.content">
<AnimatedCollapsibleContent :class="$style.content">
<div :class="$style.section">
<div :class="$style.sectionLabel">{{ i18n.baseText('instanceAi.toolCall.input') }}</div>
<ToolResultJson :value="props.toolCall.args" />
@ -96,7 +97,7 @@ const resolvedAction = computed((): 'approved' | 'denied' | 'deferred' | null =>
{{ i18n.baseText('instanceAi.toolCall.running') }}
</div>
</div>
</CollapsibleContent>
</AnimatedCollapsibleContent>
<!-- Compact pending indicator full confirmation UI is in the top-level panel -->
<div v-if="showConfirmation" :class="$style.pendingIndicator">

View file

@ -499,14 +499,14 @@ const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
<div :class="$style.footerActions">
<N8nButton
variant="outline"
size="small"
size="medium"
:class="$style.actionButton"
:label="i18n.baseText('instanceAi.workflowSetup.later')"
data-test-id="instance-ai-workflow-setup-later"
@click="handleLater"
/>
<N8nButton
size="small"
size="medium"
:class="$style.actionButton"
:label="i18n.baseText('instanceAi.credential.continueButton')"
data-test-id="instance-ai-workflow-setup-apply-button"
@ -519,7 +519,7 @@ const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
<div
v-else-if="currentDisplayCard?.type === 'single' && currentCard"
data-test-id="instance-ai-workflow-setup-card"
:class="[$style.card, { [$style.completed]: isCardComplete(currentCard) }]"
:class="$style.card"
>
<!-- Header -->
<header :class="$style.header">
@ -702,7 +702,7 @@ const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
<div :class="$style.footerActions">
<N8nButton
variant="outline"
size="small"
size="medium"
:class="$style.actionButton"
:label="i18n.baseText('instanceAi.workflowSetup.later')"
data-test-id="instance-ai-workflow-setup-later"
@ -711,7 +711,7 @@ const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
<N8nButton
v-if="currentCard.isTestable && currentCard.isTrigger && currentCard.isFirstTrigger"
size="small"
size="medium"
:class="$style.actionButton"
:label="i18n.baseText('instanceAi.workflowSetup.testTrigger')"
:disabled="isTriggerTestDisabled(currentCard)"
@ -720,7 +720,7 @@ const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
/>
<N8nButton
size="small"
size="medium"
:class="$style.actionButton"
:disabled="!anyCardComplete"
:label="i18n.baseText('instanceAi.credential.continueButton')"
@ -1012,10 +1012,7 @@ const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
padding: 0;
border: var(--border);
border-radius: var(--radius);
&.completed {
border-color: var(--color--success);
}
background-color: var(--color--background--light-3);
}
.confirmCard {
@ -1025,8 +1022,8 @@ const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
gap: var(--spacing--sm);
padding: 0;
border: var(--border);
border-color: var(--color--success);
border-radius: var(--radius);
background-color: var(--color--background--light-3);
}
.confirmSummary {

View file

@ -5,9 +5,11 @@
* Single-card plan approval UI. Shows planned tasks as an accordion with
* expandable specs, dependency info, and approve/request-changes controls.
*/
import { N8nBadge, N8nButton, N8nIcon, type IconName } from '@n8n/design-system';
import { N8nButton, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import ConfirmationFooter from './ConfirmationFooter.vue';
export interface PlannedTaskArg {
@ -35,38 +37,23 @@ const emit = defineEmits<{
'request-changes': [feedback: string];
}>();
const expandedIds = ref<Set<string>>(new Set());
const feedback = ref('');
const isResolved = ref(false);
const resolvedAction = ref<'approved' | 'changes-requested' | null>(null);
const hasFeedback = computed(() => feedback.value.trim().length > 0);
const isExpanded = ref(!props.readOnly);
const kindConfig: Record<string, { icon: IconName; label: string }> = {
'build-workflow': { icon: 'workflow', label: 'Workflow' },
'manage-data-tables': { icon: 'table', label: 'Data table' },
research: { icon: 'search', label: 'Research' },
delegate: { icon: 'share', label: 'Task' },
};
function getKind(kind: string) {
return kindConfig[kind] ?? { icon: 'circle', label: kind };
}
function toggle(id: string) {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id);
} else {
expandedIds.value.add(id);
function getDescription(task: PlannedTaskArg): string {
let text = task.spec;
if (task.deps.length) {
const depNames = task.deps.map((depId) => {
const dep = props.plannedTasks.find((t) => t.id === depId);
return dep?.title ?? depId;
});
text += `\nDepends on: ${depNames.join(', ')}`;
}
}
function getDeps(task: PlannedTaskArg): string[] {
if (!task.deps.length) return [];
return task.deps.map((depId) => {
const dep = props.plannedTasks.find((t) => t.id === depId);
return dep?.title ?? depId;
});
return text;
}
function handleApprove() {
@ -85,98 +72,68 @@ function handleRequestChanges() {
</script>
<template>
<div :class="$style.root" data-test-id="instance-ai-plan-review">
<!-- Header -->
<div :class="$style.header">
<N8nIcon icon="scroll-text" size="small" :class="$style.headerIcon" />
<span :class="$style.headerTitle">
{{ i18n.baseText('instanceAi.planReview.title') }}
</span>
<span :class="$style.taskCount">{{ plannedTasks.length }} tasks</span>
<N8nBadge v-if="props.loading" theme="secondary" :class="$style.badgeRight" size="small">
{{ i18n.baseText('instanceAi.planReview.building') }}
</N8nBadge>
<N8nBadge
v-else-if="props.readOnly || resolvedAction === 'approved'"
theme="success"
:class="$style.badgeRight"
size="small"
>
{{ i18n.baseText('instanceAi.planReview.approved') }}
</N8nBadge>
<N8nBadge v-else-if="!isResolved" theme="warning" :class="$style.badgeRight" size="small">
{{ i18n.baseText('instanceAi.planReview.awaitingApproval') }}
</N8nBadge>
</div>
<CollapsibleRoot
v-model:open="isExpanded"
:class="$style.root"
data-test-id="instance-ai-plan-review"
>
<CollapsibleTrigger as-child>
<!-- Header -->
<div :class="$style.header">
<N8nText bold>
{{ i18n.baseText('instanceAi.planReview.title') }}
</N8nText>
</div>
</CollapsibleTrigger>
<!-- Task accordion -->
<div :class="$style.tasks">
<div v-for="(task, idx) in plannedTasks" :key="task.id" :class="$style.taskItem">
<button
:class="[$style.taskRow, expandedIds.has(task.id) && $style.taskRowExpanded]"
type="button"
:disabled="!task.spec"
@click="toggle(task.id)"
>
<span :class="$style.taskNumber">{{ idx + 1 }}</span>
<N8nIcon
v-if="task.kind"
:icon="getKind(task.kind).icon"
size="small"
:class="$style.taskKindIcon"
/>
<span :class="$style.taskTitle">{{ task.title }}</span>
<span v-if="task.kind" :class="$style.taskKindBadge">{{ getKind(task.kind).label }}</span>
<N8nIcon
v-if="task.spec"
:icon="expandedIds.has(task.id) ? 'chevron-up' : 'chevron-down'"
size="small"
:class="$style.chevron"
/>
</button>
<AnimatedCollapsibleContent>
<div :class="$style.tasks">
<div v-for="(task, idx) in plannedTasks" :key="task.id" :class="$style.taskItem">
<div :class="$style.taskRow">
<span :class="$style.taskNumber">{{ idx + 1 }}</span>
<N8nText :class="$style.taskTitle">{{ task.title }}</N8nText>
</div>
<!-- Expanded detail (only when spec available) -->
<div v-if="expandedIds.has(task.id) && task.spec" :class="$style.taskDetail">
<p :class="$style.taskSpec">{{ task.spec }}</p>
<div v-if="getDeps(task).length > 0" :class="$style.taskDeps">
<span :class="$style.depsLabel">Depends on:</span>
<span v-for="dep in getDeps(task)" :key="dep" :class="$style.depChip">{{ dep }}</span>
<div v-if="task.spec" :class="$style.taskDetail">
<N8nText tag="p" color="text-light" :class="$style.taskSpec">{{
getDescription(task)
}}</N8nText>
</div>
</div>
</div>
</div>
<!-- Approval footer (hidden during loading and after resolution) -->
<ConfirmationFooter v-if="!isResolved && !props.readOnly && !props.loading" layout="column">
<textarea
v-model="feedback"
:class="$style.feedbackTextarea"
:placeholder="i18n.baseText('instanceAi.planReview.feedbackPlaceholder')"
:disabled="disabled"
rows="2"
/>
<div :class="$style.actions">
<N8nButton
variant="outline"
size="small"
:disabled="disabled || !hasFeedback"
data-test-id="instance-ai-plan-request-changes"
@click="handleRequestChanges"
>
{{ i18n.baseText('instanceAi.planReview.requestChanges') }}
</N8nButton>
<N8nButton
variant="solid"
size="small"
<!-- Approval footer (hidden during loading and after resolution) -->
<ConfirmationFooter v-if="!isResolved && !props.readOnly && !props.loading" layout="column">
<N8nInput
v-model="feedback"
type="textarea"
:placeholder="i18n.baseText('instanceAi.planReview.feedbackPlaceholder')"
:disabled="disabled"
data-test-id="instance-ai-plan-approve"
@click="handleApprove"
>
{{ i18n.baseText('instanceAi.planReview.approve') }}
</N8nButton>
</div>
</ConfirmationFooter>
</div>
:rows="2"
/>
<div :class="$style.actions">
<N8nButton
variant="outline"
size="medium"
:disabled="disabled || !hasFeedback"
data-test-id="instance-ai-plan-request-changes"
@click="handleRequestChanges"
>
{{ i18n.baseText('instanceAi.planReview.requestChanges') }}
</N8nButton>
<N8nButton
variant="solid"
size="medium"
:disabled="disabled"
data-test-id="instance-ai-plan-approve"
@click="handleApprove"
>
{{ i18n.baseText('instanceAi.planReview.approve') }}
</N8nButton>
</div>
</ConfirmationFooter>
</AnimatedCollapsibleContent>
</CollapsibleRoot>
</template>
<style lang="scss" module>
@ -185,6 +142,7 @@ function handleRequestChanges() {
border-radius: var(--radius--lg);
margin: var(--spacing--2xs) 0;
overflow: hidden;
background-color: var(--color--background--light-3);
}
.header {
@ -195,34 +153,14 @@ function handleRequestChanges() {
border-bottom: var(--border);
}
.headerIcon {
color: var(--color--primary);
flex-shrink: 0;
}
.headerTitle {
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--bold);
color: var(--color--text);
}
.taskCount {
font-size: var(--font-size--3xs);
color: var(--color--text--tint-1);
}
.badgeRight {
margin-left: auto;
}
.tasks {
display: flex;
flex-direction: column;
}
.taskItem {
& + & {
border-top: var(--border);
&:first-child {
padding-top: var(--spacing--3xs);
}
}
@ -230,18 +168,7 @@ function handleRequestChanges() {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
width: 100%;
padding: var(--spacing--2xs) var(--spacing--sm);
background: none;
border: none;
cursor: pointer;
font-family: var(--font-family);
text-align: left;
transition: background-color 0.12s ease;
&:hover {
background: var(--color--foreground--tint-2);
}
padding: var(--spacing--2xs) var(--spacing--sm) 0;
}
.taskNumber {
@ -253,113 +180,30 @@ function handleRequestChanges() {
border-radius: 50%;
background: var(--color--foreground);
color: var(--color--text--tint-1);
font-size: var(--font-size--3xs);
font-size: var(--font-size--xs);
font-weight: var(--font-weight--bold);
flex-shrink: 0;
}
.taskKindIcon {
color: var(--color--text--tint-1);
flex-shrink: 0;
}
.taskTitle {
flex: 1;
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--bold);
color: var(--color--text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.taskKindBadge {
font-size: var(--font-size--3xs);
color: var(--color--text--tint-1);
padding: var(--spacing--5xs) var(--spacing--4xs);
background: var(--color--foreground);
border-radius: var(--radius--sm);
white-space: nowrap;
flex-shrink: 0;
}
.chevron {
color: var(--color--text--tint-2);
flex-shrink: 0;
}
.taskDetail {
padding: 0 var(--spacing--sm) var(--spacing--xs);
padding: var(--spacing--4xs) var(--spacing--sm) var(--spacing--2xs);
padding-left: calc(var(--spacing--sm) + 20px + var(--spacing--2xs));
animation: detail-slide-in 0.15s ease;
}
@keyframes detail-slide-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.taskSpec {
margin: 0;
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
line-height: var(--line-height--xl);
white-space: pre-wrap;
word-break: break-word;
}
.taskDeps {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
margin-top: var(--spacing--3xs);
flex-wrap: wrap;
}
.depsLabel {
font-size: var(--font-size--3xs);
color: var(--color--text--tint-2);
white-space: nowrap;
}
.depChip {
font-size: var(--font-size--3xs);
color: var(--color--text--tint-1);
padding: var(--spacing--5xs) var(--spacing--4xs);
background: var(--color--foreground);
border-radius: var(--radius--sm);
white-space: nowrap;
}
.feedbackTextarea {
width: 100%;
padding: var(--spacing--2xs);
border: var(--border);
border-radius: var(--radius);
font-size: var(--font-size--2xs);
font-family: var(--font-family);
background: var(--color--background);
color: var(--color--text);
resize: vertical;
outline: none;
&:focus {
border-color: var(--color--primary);
}
&::placeholder {
color: var(--color--text--tint-2);
}
}
.actions {
display: flex;
justify-content: flex-end;

View file

@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { InstanceAiAgentNode } from '@n8n/api-types';
import { N8nIcon } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed } from 'vue';
import { getToolIcon } from '../toolLabels';
import { getGroupToolIcons, type ResponseGroupSegment } from '../useTimelineGrouping';
import AgentTimeline from './AgentTimeline.vue';
import TimelineStepButton from './TimelineStepButton.vue';
const props = defineProps<{
group: ResponseGroupSegment;
agentNode: InstanceAiAgentNode;
/** Whether this is the last response group in the timeline. */
isLast?: boolean;
}>();
const i18n = useI18n();
const summaryText = computed(() => {
const { toolCallCount, textCount, questionCount, childCount } = props.group;
const parts: string[] = [];
if (toolCallCount > 0) {
parts.push(
i18n.baseText('instanceAi.activitySummary.toolCalls', {
interpolate: { count: `${toolCallCount}` },
}),
);
}
if (textCount > 0) {
parts.push(
i18n.baseText('instanceAi.activitySummary.messages', {
interpolate: { count: `${textCount}` },
}),
);
}
if (questionCount > 0) {
parts.push(
i18n.baseText('instanceAi.activitySummary.questions', {
interpolate: { count: `${questionCount}` },
}),
);
}
if (childCount > 0) {
parts.push(
i18n.baseText('instanceAi.activitySummary.subagents', {
interpolate: { count: `${childCount}` },
}),
);
}
return parts.join(', ');
});
const toolIcons = computed(() =>
getGroupToolIcons(props.group, props.agentNode.toolCalls, getToolIcon),
);
/** Whether any tool call in this group is still loading. */
const hasLoadingToolCalls = computed(() =>
props.group.entries.some((e) => {
if (e.type !== 'tool-call') return false;
const tc = props.agentNode.toolCalls.find((t) => t.toolCallId === e.toolCallId);
return tc?.isLoading;
}),
);
/** Whether any child agent in this group is still active. */
const hasActiveChildren = computed(() =>
props.group.entries.some((e) => {
if (e.type !== 'child') return false;
const child = props.agentNode.children.find((c) => c.agentId === e.agentId);
return child?.status === 'active';
}),
);
/** Don't collapse the last group while the agent is still streaming. */
const isLastAndStreaming = computed(() => props.isLast && props.agentNode.status === 'active');
/** Whether this group has enough content to justify a collapsible wrapper. */
const isCollapsible = computed(
() =>
!isLastAndStreaming.value &&
!hasLoadingToolCalls.value &&
!hasActiveChildren.value &&
(props.group.toolCallCount > 1 || props.group.childCount > 0),
);
</script>
<template>
<!-- Collapsible: groups with generic tool calls or children -->
<CollapsibleRoot v-if="isCollapsible" v-slot="{ open: isOpen }">
<CollapsibleTrigger as-child>
<TimelineStepButton size="medium">
<template #icon>
<N8nIcon v-if="!isOpen" icon="chevron-right" size="small" />
<N8nIcon v-else icon="chevron-down" size="small" />
</template>
<span :class="$style.summaryLabel">
{{ summaryText }}
<span v-if="toolIcons.length > 0" :class="$style.summaryIcons">
<N8nIcon v-for="icon in toolIcons" :key="icon" :icon="icon" size="small" />
</span>
</span>
</TimelineStepButton>
</CollapsibleTrigger>
<AnimatedCollapsibleContent>
<AgentTimeline :agent-node="props.agentNode" :visible-entries="props.group.entries" />
</AnimatedCollapsibleContent>
</CollapsibleRoot>
<!-- Flat: groups with only text + special UI (questions, plan-review) -->
<AgentTimeline v-else :agent-node="props.agentNode" :visible-entries="props.group.entries" />
</template>
<style lang="scss" module>
.summaryLabel {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.summaryIcons {
display: inline-flex;
gap: var(--spacing--4xs);
opacity: 0.6;
}
</style>

View file

@ -16,7 +16,7 @@ const props = withDefaults(
}>(),
{
variant: 'solid',
size: 'small',
size: 'medium',
caretAriaLabel: 'More options',
},
);

View file

@ -2,7 +2,8 @@
import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types';
import { N8nButton, N8nIcon, type IconName } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed } from 'vue';
import { getToolIcon, useToolLabel } from '../toolLabels';
import ButtonLike from './ButtonLike.vue';
@ -130,11 +131,11 @@ const steps = computed((): TimelineStep[] => {
<template v-else>{{ step.label }}</template>
</N8nButton>
</CollapsibleTrigger>
<CollapsibleContent :class="$style.toggleContent">
<AnimatedCollapsibleContent :class="$style.toggleContent">
<DataSection>
<InstanceAiMarkdown :content="step.textContent!" />
</DataSection>
</CollapsibleContent>
</AnimatedCollapsibleContent>
</CollapsibleRoot>
<ButtonLike v-else>
<N8nIcon :icon="step.icon" size="small" />

View file

@ -1,7 +1,8 @@
<script lang="ts" setup>
import type { InstanceAiToolCallState } from '@n8n/api-types';
import { N8nCallout, N8nIcon } from '@n8n/design-system';
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { getToolIcon, useToolLabel } from '../toolLabels';
import DataSection from './DataSection.vue';
import TimelineStepButton from './TimelineStepButton.vue';
@ -59,7 +60,7 @@ function getDisplayLabel(tc: InstanceAiToolCallState): string {
{{ props.label ?? getDisplayLabel(props.toolCall) }}
</TimelineStepButton>
</CollapsibleTrigger>
<CollapsibleContent>
<AnimatedCollapsibleContent>
<DataSection v-if="props.toolCall.args">
<ToolResultJson :value="props.toolCall.args" />
</DataSection>
@ -69,6 +70,6 @@ function getDisplayLabel(tc: InstanceAiToolCallState): string {
<N8nCallout v-if="props.toolCall.error !== undefined" theme="danger">
{{ props.toolCall.error }}
</N8nCallout>
</CollapsibleContent>
</AnimatedCollapsibleContent>
</CollapsibleRoot>
</template>

View file

@ -100,7 +100,7 @@ function downloadFullJson() {
<style lang="scss" module>
.json {
font-family: monospace;
font-size: var(--font-size--3xs);
font-size: var(--font-size--sm);
line-height: var(--line-height--xl);
white-space: pre-wrap;
word-break: break-word;

View file

@ -214,7 +214,11 @@ function patchStreamingTextTimeline(
}
const updatedLast = timeline.at(-1);
if (updatedLast?.type !== 'text') return false;
node.timeline.push({ type: 'text', content: updatedLast.content });
node.timeline.push({
type: 'text',
content: updatedLast.content,
...(updatedLast.responseId ? { responseId: updatedLast.responseId } : {}),
});
return true;
}

View file

@ -0,0 +1,169 @@
import type {
InstanceAiAgentNode,
InstanceAiTimelineEntry,
InstanceAiToolCallState,
} from '@n8n/api-types';
import type { IconName } from '@n8n/design-system';
import { computed, type ComputedRef, type Ref } from 'vue';
import { extractArtifacts, HIDDEN_TOOLS, type ArtifactInfo } from './agentTimeline.utils';
/** Render hints for tool calls that show as special UI — not as generic "tool call" steps. */
const SPECIAL_RENDER_HINTS = new Set(['tasks', 'delegate', 'builder', 'data-table', 'researcher']);
/** Returns true if a tool call renders as a generic ToolCallStep (not special UI). */
function isGenericToolCall(tc: InstanceAiToolCallState): boolean {
if (HIDDEN_TOOLS.has(tc.toolName)) return false;
if (tc.renderHint && SPECIAL_RENDER_HINTS.has(tc.renderHint)) return false;
if (tc.confirmation?.inputType === 'questions' || tc.confirmation?.inputType === 'plan-review') {
return false;
}
return true;
}
export interface ResponseGroupSegment {
kind: 'response-group';
responseId: string | undefined;
entries: InstanceAiTimelineEntry[];
/** Visible tool call count (excludes hidden and special-render tools). */
toolCallCount: number;
/** Number of text entries inside this group (intermediate thinking text). */
textCount: number;
/** Number of answered question forms in this group. */
questionCount: number;
/** Number of child agent entries in this group. */
childCount: number;
/** Artifacts produced by child agents in this group. */
artifacts: ArtifactInfo[];
}
export interface TrailingTextSegment {
kind: 'trailing-text';
content: string;
}
export type TimelineSegment = ResponseGroupSegment | TrailingTextSegment;
/**
* Groups an agent's timeline for collapsed rendering when the run is complete.
*
* Text entries are always rendered inline (visible). Tool calls and child agents
* are grouped into collapsible `response-group` segments. Text splits groups
* even entries from the same API response are separated if text appears between them.
*
* Returns null when grouping is unavailable (no `responseId` data, or nothing to collapse).
*/
export function useTimelineGrouping(
agentNode: Ref<InstanceAiAgentNode> | ComputedRef<InstanceAiAgentNode>,
): ComputedRef<TimelineSegment[] | null> {
return computed(() => {
// Skip grouping while the agent is still running — the result is only
// used for the collapsed/completed view and recomputing on every SSE
// chunk is wasted work.
if (agentNode.value.status === 'active') return null;
const timeline = agentNode.value.timeline;
if (timeline.length === 0) return null;
// Check if any entry has a responseId — if not, skip grouping (old data).
const hasResponseIds = timeline.some((e) => e.responseId !== undefined);
if (!hasResponseIds) return null;
// Build segments: text entries are always inline (visible), non-text
// entries are grouped. Text splits groups — even within the same responseId.
const segments: TimelineSegment[] = [];
let currentGroup: ResponseGroupSegment | null = null;
function newGroup(responseId: string | undefined): ResponseGroupSegment {
return {
kind: 'response-group',
responseId,
entries: [],
toolCallCount: 0,
textCount: 0,
questionCount: 0,
childCount: 0,
artifacts: [],
};
}
for (const entry of timeline) {
if (entry.type === 'text') {
// Text from the same API response as the current group stays inside
// (intermediate "thinking" text). Otherwise it renders inline.
if (currentGroup && entry.responseId === currentGroup.responseId) {
currentGroup.entries.push(entry);
currentGroup.textCount++;
} else {
currentGroup = null;
segments.push({ kind: 'trailing-text', content: entry.content });
}
} else if (entry.type === 'tool-call') {
if (!currentGroup || currentGroup.responseId !== entry.responseId) {
currentGroup = newGroup(entry.responseId);
segments.push(currentGroup);
}
currentGroup.entries.push(entry);
const tc = agentNode.value.toolCalls.find((t) => t.toolCallId === entry.toolCallId);
if (tc && isGenericToolCall(tc)) {
currentGroup.toolCallCount++;
} else if (tc?.confirmation?.inputType === 'questions' && !tc.isLoading) {
currentGroup.questionCount++;
}
} else if (entry.type === 'child') {
if (!currentGroup || currentGroup.responseId !== entry.responseId) {
currentGroup = newGroup(entry.responseId);
segments.push(currentGroup);
}
currentGroup.entries.push(entry);
currentGroup.childCount++;
const child = agentNode.value.children.find((c) => c.agentId === entry.agentId);
if (child) {
currentGroup.artifacts.push(...extractArtifacts(child));
}
}
}
// Extract trailing text from each response group — the last text entry
// is usually the conclusion after tool calls and should be visible.
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (seg.kind !== 'response-group') continue;
const last = seg.entries.at(-1);
if (last?.type === 'text') {
seg.entries.pop();
seg.textCount--;
segments.splice(i + 1, 0, { kind: 'trailing-text', content: last.content });
}
}
// Drop empty response groups (only hidden tool calls, no visible content).
const flattened = segments.filter((seg) => {
if (seg.kind !== 'response-group') return true;
return seg.toolCallCount > 0 || seg.childCount > 0;
});
// If there are no collapsible response groups, skip grouping entirely.
const hasCollapsibleGroups = flattened.some((s) => s.kind === 'response-group');
if (!hasCollapsibleGroups) return null;
return flattened;
});
}
/** Collect distinct tool icons from tool calls within a group's entries. */
export function getGroupToolIcons(
group: ResponseGroupSegment,
toolCalls: InstanceAiToolCallState[],
getIcon: (toolName: string) => IconName,
): IconName[] {
const icons = new Set<IconName>();
for (const entry of group.entries) {
if (entry.type === 'tool-call') {
const tc = toolCalls.find((t) => t.toolCallId === entry.toolCallId);
if (tc && !HIDDEN_TOOLS.has(tc.toolName)) {
icons.add(getIcon(tc.toolName));
}
}
}
return [...icons];
}