mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
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:
parent
a12d368482
commit
316d5bda80
37 changed files with 721 additions and 395 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
|
||||
:global(.n8n-icon) {
|
||||
flex-shrink: 0;
|
||||
align-self: baseline;
|
||||
margin-top: var(--spacing--2xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -16,7 +16,7 @@ const props = withDefaults(
|
|||
}>(),
|
||||
{
|
||||
variant: 'solid',
|
||||
size: 'small',
|
||||
size: 'medium',
|
||||
caretAriaLabel: 'More options',
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
Loading…
Reference in a new issue