feat(editor): Add plan stop and continue controls in Agent Preview (no-changelog)

This commit is contained in:
Eugene Molodkin 2026-10-09 13:17:52 +02:00
parent f8b7f9ffc7
commit 83e956badb
No known key found for this signature in database
19 changed files with 1407 additions and 146 deletions

View File

@ -8416,6 +8416,16 @@
"agents.chat.backgroundTasks.status.completed": "Completed",
"agents.chat.backgroundTasks.status.failed": "Failed",
"agents.chat.backgroundTasks.status.cancelled": "Canceled",
"agents.chat.tasks.stopAll": "Stop all tasks",
"agents.chat.tasks.stopping": "Stopping tasks…",
"agents.chat.tasks.reportFailed": "Tasks stopped. The agent could not write a summary. You can ask to continue.",
"agents.chat.tasks.stopped": "Stopped",
"agents.chat.tasks.retry": "Stop all tasks again",
"agents.chat.tasks.stoppedNotice": "Tasks stopped. Completed: {completed}. Canceled: {canceled}. Your queued messages stay held until you select Send.",
"agents.chat.tasks.stopFailedTitle": "Some tasks still need to stop",
"agents.chat.tasks.stopFailed": "These tasks still need to stop. Select Stop all tasks again:",
"agents.chat.queue.send": "Send",
"agents.chat.queue.sendError": "Could not send the held message",
"agents.chat.plan.title": "Plan: {title}",
"agents.chat.plan.progress": "{done} of {total} task done | {done} of {total} tasks done",
"agents.chat.plan.viewTrace": "View trace",

View File

@ -359,6 +359,25 @@ describe('AgentChatMessageList', () => {
wrapper.unmount();
});
it('hides empty saved result events and keeps the acknowledgement text', async () => {
const message: ChatMessage = {
id: 'stop:assistant',
executionId: 'stop',
role: 'assistant',
content: '',
backgroundJobSignal: { tasks: [] },
};
const wrapper = mount(AgentChatMessageList, {
props: { messages: [message], messagingState: 'idle' },
});
expect(wrapper.find('[data-testid="agent-chat-background-job-signal"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="markdown-chunk"]').exists()).toBe(false);
await wrapper.setProps({ messages: [{ ...message, content: 'The plan is stopped.' }] });
expect(wrapper.find('[data-testid="agent-chat-background-job-signal"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="markdown-chunk"]').text()).toBe('The plan is stopped.');
wrapper.unmount();
});
it('keeps signal expansion and order when the response gains tools and text', async () => {
const message: ChatMessage = {
id: 'wake:assistant',

View File

@ -77,8 +77,19 @@ const isCancellingMock = ref(false);
const backgroundJobsMock = ref<AgentBackgroundJobDto[]>([]);
const respondToApprovalMock = vi.fn();
const stopAllMock = vi.fn();
const stopPlanMock = vi.fn(async () => await stopAllMock());
const planStopState = ref<import('@n8n/api-types').AgentTaskCancellationState | null>(null);
vi.mock('../composables/useAgentTaskCancellation', () => ({
useAgentTaskCancellation: () => ({
state: planStopState,
isStopping: isStoppingMock,
stopAll: stopPlanMock,
}),
}));
const isStoppingMock = ref(false);
const showErrorMock = vi.fn();
const sendHeldMessageMock = vi.fn();
const updateQueuedMessageMock = vi.fn().mockResolvedValue('updated');
vi.mock('../composables/useAgentBackgroundJobs', () => ({
useAgentBackgroundJobs: () => ({
jobs: backgroundJobsMock,
@ -112,6 +123,11 @@ vi.mock('@n8n/i18n', () => {
options?: { interpolate?: Record<string, string | number>; adjustToNumber?: number },
) => {
const translations: Record<string, string> = {
'agents.chat.tasks.stopAll': 'Stop all tasks',
'agents.chat.tasks.stopping': 'Stopping tasks…',
'agents.chat.tasks.stopped': 'Stopped',
'agents.chat.tasks.retry': 'Retry stopping',
'agents.chat.queue.send': 'Send',
'agents.chat.input.placeholder.withAgent': `Message ${options?.interpolate?.agentName}…`,
'chat.messageQueue.title': `${options?.interpolate?.count} ${options?.adjustToNumber === 1 ? 'message' : 'messages'} up next`,
'agents.chat.attachments.unsupportedType': `${options?.interpolate?.fileName} is not a supported file type`,
@ -323,6 +339,8 @@ vi.mock('../composables/useAgentChatStream', () => ({
steeringQueueIds: steeringQueueIdsMock,
canSteer: canSteerMock,
steerQueuedMessage: steerQueuedMessageMock,
sendHeldMessage: sendHeldMessageMock,
updateQueuedMessage: updateQueuedMessageMock,
removeQueuedMessage: removeQueuedMessageMock,
reorderQueuedMessage: reorderQueuedMessageMock,
isReorderingQueue: isReorderingQueueMock,
@ -379,6 +397,7 @@ describe('AgentChatPanel', () => {
isCancellingMock.value = false;
backgroundJobsMock.value = [];
stopAllMock.mockReset();
planStopState.value = null;
isStoppingMock.value = false;
respondToApprovalMock.mockReset().mockResolvedValue(undefined);
fatalErrorMock.value = null;
@ -1109,6 +1128,80 @@ describe('AgentChatPanel', () => {
wrapper.unmount();
});
it('sends only the selected held message and keeps the other rows editable', async () => {
queuedMessagesMock.value = [
{ id: 'held-1', message: 'First', held: true, steeringExecutionId: null, createdAt: '' },
{ id: 'held-2', message: 'Second', held: true, steeringExecutionId: null, createdAt: '' },
];
const wrapper = mountPanel();
const rows = wrapper.findAll('[data-testid="chat-queued-message"]');
const send = rows[0].findAll('button').find((button) => button.text() === 'Send');
expect(send).toBeDefined();
await send!.trigger('click');
expect(sendHeldMessageMock).toHaveBeenCalledExactlyOnceWith('held-1');
expect(steerQueuedMessageMock).not.toHaveBeenCalled();
expect(rows[1].find('[aria-label="generic.edit"]').exists()).toBe(true);
expect(rows[1].find('[aria-label="generic.delete"]').exists()).toBe(true);
wrapper.unmount();
});
it('saves edits to a held message without removing or sending it', async () => {
queuedMessagesMock.value = [
{ id: 'held-1', message: 'Original', held: true, steeringExecutionId: null, createdAt: '' },
];
const wrapper = mountPanel();
await wrapper
.get('[data-testid="chat-queued-message"] [aria-label="generic.edit"]')
.trigger('click');
const editor = wrapper.get('textarea[aria-label="agents.chat.queue.edit"]');
await editor.setValue('Changed');
await editor.trigger('keydown', { key: 'Enter' });
await flushPromises();
expect(updateQueuedMessageMock).toHaveBeenCalledWith('held-1', 'Changed');
expect(removeQueuedMessageMock).not.toHaveBeenCalled();
expect(sendHeldMessageMock).not.toHaveBeenCalled();
wrapper.unmount();
});
it('restores composer focus when a plan stop finishes after the request returns', async () => {
backgroundJobsMock.value = [
{ id: 'job-1', kind: 'subagent', title: 'Research', status: 'running', startedAt: '' },
];
const plan = planView();
messagesMock.value = [planMessage(plan)];
const wrapper = mountPanel(
{ backgroundJobsActive: true, continueSessionId: 't1' },
document.body,
);
try {
await wrapper.get('[data-testid="agent-chat-plan"] button').trigger('click');
const button = wrapper.get<HTMLButtonElement>('[data-testid="agent-chat-plan-stop"]');
button.element.focus();
stopAllMock.mockImplementationOnce(async () => {
isStoppingMock.value = true;
button.element.blur();
});
await button.trigger('click');
await flushPromises();
expect(document.activeElement).toBe(document.body);
backgroundJobsMock.value = [];
planStopState.value = {
planId: plan.planId,
status: 'stopped',
requestedAt: new Date().toISOString(),
failures: [],
summary: { completed: 0, canceled: 0 },
plan: null,
heldQueueIds: [],
};
isStoppingMock.value = false;
await flushPromises();
expect(document.activeElement).toBe(wrapper.get('textarea').element);
} finally {
wrapper.unmount();
}
});
it('keeps two pending messages in the composer below background tasks and removes them without adding conversation bubbles', async () => {
queuedMessagesMock.value = [
{
@ -1496,6 +1589,230 @@ describe('AgentChatPanel', () => {
});
describe('plan card', () => {
const job: AgentBackgroundJobDto = {
id: 'plan-job',
kind: 'subagent',
title: 'Research',
status: 'running',
startedAt: '2026-10-01T10:00:00Z',
};
it('keeps Stop visible between background jobs while the plan has unfinished tasks', async () => {
const plan = planView();
messagesMock.value = [planMessage(plan)];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
try {
const card = wrapper.getComponent(AgentChatPlan);
await card.get('button').trigger('click');
const stop = card.get('[data-testid="agent-chat-plan-stop"]');
expect(stop.attributes('disabled')).toBeUndefined();
await stop.trigger('click');
expect(stopAllMock).toHaveBeenCalledOnce();
expect(stopPlanMock).toHaveBeenCalledOnce();
backgroundJobsMock.value = [job];
await nextTick();
expect(stop.attributes('disabled')).toBeUndefined();
backgroundJobsMock.value = [{ ...job, status: 'completed' }];
await nextTick();
expect(card.get('[data-testid="agent-chat-plan-stop"]').element).toBe(stop.element);
expect(stop.attributes('disabled')).toBeUndefined();
backgroundJobsMock.value = [];
await nextTick();
expect(card.get('[data-testid="agent-chat-plan-stop"]').element).toBe(stop.element);
backgroundJobsMock.value = [{ ...job, id: 'next-job' }];
await nextTick();
expect(stop.attributes('disabled')).toBeUndefined();
expect(card.get('button').attributes('aria-expanded')).toBe('true');
backgroundJobsMock.value = [];
messagesMock.value = [planMessage({ ...plan, closed: true })];
await nextTick();
expect(card.find('[data-testid="agent-chat-plan-stop"]').exists()).toBe(false);
} finally {
wrapper.unmount();
}
});
it('uses the saved plan stop state when no child jobs exist', async () => {
const plan = planView();
messagesMock.value = [planMessage(plan)];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
const card = wrapper.getComponent(AgentChatPlan);
await card.get('button').trigger('click');
await card.get('[data-testid="agent-chat-plan-stop"]').trigger('click');
expect(stopAllMock).toHaveBeenCalledOnce();
planStopState.value = {
planId: plan.planId,
status: 'stopping',
requestedAt: new Date().toISOString(),
failures: [],
summary: { completed: 0, canceled: 0 },
plan: null,
heldQueueIds: [],
};
await nextTick();
expect(card.props('stopping')).toBe(true);
planStopState.value.status = 'stopped';
await nextTick();
expect(card.props('stopped')).toBe(true);
expect(card.find('[data-testid="agent-chat-plan-stop"]').exists()).toBe(false);
planStopState.value.status = 'resumed';
await nextTick();
expect(card.props('stopped')).toBe(false);
expect(card.props('canStop')).toBe(true);
wrapper.unmount();
});
it('waits for jobs to stop and restores saved task states when they continue', async () => {
messagesMock.value = [planMessage(planView())];
backgroundJobsMock.value = [job];
const wrapper = mountPanel(
{ backgroundJobsActive: true, continueSessionId: 't1' },
document.body,
);
try {
const card = wrapper.getComponent(AgentChatPlan);
await card.get('button').trigger('click');
const stop = card.get<HTMLButtonElement>('[data-testid="agent-chat-plan-stop"]');
stop.element.focus();
stopAllMock.mockImplementationOnce(async () => {
backgroundJobsMock.value = [{ ...job, pauseRequested: true }];
});
await stop.trigger('click');
await flushPromises();
expect(stopAllMock).toHaveBeenCalledOnce();
expect(stopGeneratingMock).not.toHaveBeenCalled();
expect(card.get('button').text()).toContain('Stopping tasks…');
expect(stop.attributes('disabled')).toBeDefined();
expect(card.props('stopped')).toBe(false);
backgroundJobsMock.value = [{ ...job, pauseRequested: true, status: 'paused' }];
await flushPromises();
expect(card.get('button').text()).toContain('Stopped');
expect(card.find('[data-status="in_progress"]').exists()).toBe(false);
expect(card.findAll('[data-status="stopped"]')).toHaveLength(3);
expect(card.get('button').attributes('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(wrapper.get('textarea').element);
backgroundJobsMock.value = [job];
await nextTick();
expect(card.props('stopped')).toBe(false);
expect(card.findAll('[data-status="in_progress"]')).toHaveLength(1);
expect(card.findAll('[data-status="pending"]')).toHaveLength(2);
} finally {
wrapper.unmount();
}
});
it.each(['paused', 'cancelled'] as const)(
'restores a %s stop outcome after reload',
async (status) => {
messagesMock.value = [planMessage(planView())];
backgroundJobsMock.value = [{ ...job, status, pauseRequested: true }];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
expect(wrapper.getComponent(AgentChatPlan).props('stopped')).toBe(true);
backgroundJobsMock.value.push({ ...job, id: 'active-sibling' });
await nextTick();
expect(wrapper.getComponent(AgentChatPlan).props('stopped')).toBe(false);
wrapper.unmount();
},
);
it('keeps a failed workflow stop retryable after reload', async () => {
messagesMock.value = [planMessage(planView())];
backgroundJobsMock.value = [{ ...job, kind: 'workflow', pauseRequested: true }];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
const card = wrapper.getComponent(AgentChatPlan);
await card.get('button').trigger('click');
expect(card.props('stopped')).toBe(false);
const stop = card.get('[data-testid="agent-chat-plan-stop"]');
expect(stop.attributes('disabled')).toBeUndefined();
expect(stop.text()).toBe('Retry stopping');
await stop.trigger('click');
expect(stopAllMock).toHaveBeenCalledOnce();
wrapper.unmount();
});
it('does not restore an old stop after continued work finishes', () => {
messagesMock.value = [planMessage(planView())];
backgroundJobsMock.value = [
{ ...job, status: 'completed', settledAt: '2026-10-01T10:10:00Z' },
{
...job,
id: 'workflow',
kind: 'workflow',
status: 'cancelled',
pauseRequested: true,
settledAt: '2026-10-01T10:05:00Z',
},
];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
expect(wrapper.getComponent(AgentChatPlan).props('stopped')).toBe(false);
wrapper.unmount();
});
it('does not apply an earlier stop to a newer or closed plan', async () => {
messagesMock.value = [planMessage(planView({ startedAt: '2026-10-01T11:00:00Z' }))];
backgroundJobsMock.value = [
{ ...job, status: 'paused', pauseRequested: true, settledAt: '2026-10-01T10:05:00Z' },
];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
expect(wrapper.getComponent(AgentChatPlan).props('stopped')).toBe(false);
messagesMock.value = [planMessage(planView({ closed: true }))];
await nextTick();
expect(wrapper.getComponent(AgentChatPlan).props('stopped')).toBe(false);
wrapper.unmount();
});
it('keeps plan stopping available only in Preview', async () => {
messagesMock.value = [planMessage(planView())];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
expect(wrapper.getComponent(AgentChatPlan).props('canStop')).toBe(true);
backgroundJobsMock.value = [{ ...job, status: 'paused', pauseRequested: true }];
await wrapper.setProps({ channel: 'n8n-chat' });
expect(wrapper.getComponent(AgentChatPlan).props('stopped')).toBe(false);
expect(wrapper.getComponent(AgentChatPlan).props('canStop')).toBe(false);
isStoppingMock.value = true;
await nextTick();
expect(wrapper.getComponent(AgentChatPlan).props('showStop')).toBe(false);
wrapper.unmount();
});
it('keeps the expanded plan and Stop available while approval replaces the composer', async () => {
backgroundJobsMock.value = [
{
id: 'job-1',
kind: 'subagent',
title: 'Research',
status: 'running',
startedAt: '2026-10-01T10:00:00Z',
},
];
messagesMock.value = [planMessage(planView())];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
await wrapper.get('[data-testid="agent-chat-plan"] button').trigger('click');
messagesMock.value.push({
...openInteractiveMessage(),
interactive: {
toolName: APPROVAL_TOOL_NAME,
toolCallId: 'tc-1',
runId: 'run-1',
input: { type: 'approval', toolName: 'send_message', args: {} },
},
});
await nextTick();
expect(wrapper.find('[data-testid="agent-chat-approvals"]').exists()).toBe(true);
expect(
wrapper.get('[data-testid="agent-chat-plan"] button').attributes('aria-expanded'),
).toBe('true');
await wrapper.get('[data-testid="agent-chat-plan-stop"]').trigger('click');
expect(stopAllMock).toHaveBeenCalledOnce();
messagesMock.value.pop();
await nextTick();
expect(
wrapper.get('[data-testid="agent-chat-plan"] button').attributes('aria-expanded'),
).toBe('true');
wrapper.unmount();
});
it('renders live plan results above the input without changing submission', async () => {
const wrapper = mountPanel();
expect(wrapper.findComponent(AgentChatPlan).exists()).toBe(false);
@ -1541,8 +1858,8 @@ describe('AgentChatPanel', () => {
wrapper.unmount();
});
it('keeps background jobs visible and independent of the plan', async () => {
messagesMock.value = [planMessage(planView({ closed: true }))];
it('shows the background card only when no plan exists', async () => {
messagesMock.value = [planMessage(planView())];
backgroundJobsMock.value = [
{
id: 'job-1',
@ -1554,17 +1871,19 @@ describe('AgentChatPanel', () => {
];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
const card = wrapper.getComponent(AgentChatPlan);
const job = wrapper.get('[data-testid="agent-background-jobs"]');
expect(
card.element.compareDocumentPosition(job.element) & Node.DOCUMENT_POSITION_PRECEDING,
).toBeTruthy();
await job.get('button').trigger('click');
expect(card.get('button').attributes('aria-expanded')).toBe('false');
expect(job.text()).toContain('Separate work');
expect(wrapper.find('[data-testid="agent-background-jobs"]').exists()).toBe(false);
await card.get('button').trigger('click');
expect(card.find('[data-testid="agent-chat-plan-stop"]').exists()).toBe(true);
messagesMock.value.push(planMessage(planView({ closed: true }), { tool: 'update_plan' }));
await nextTick();
expect(wrapper.find('[data-testid="agent-background-jobs"]').exists()).toBe(false);
messagesMock.value.push(planMessage(null, { tool: 'read_plan' }));
await nextTick();
expect(wrapper.findComponent(AgentChatPlan).exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-background-jobs"]').exists()).toBe(true);
const job = wrapper.get('[data-testid="agent-background-jobs"]');
await job.get('button').trigger('click');
expect(job.text()).toContain('Separate work');
expect(job.find('[data-testid="agent-background-jobs-stop"]').exists()).toBe(true);
wrapper.unmount();
});
@ -1637,7 +1956,19 @@ describe('AgentChatPanel', () => {
}
});
it('restores composer focus when workflows stop and a completed task remains', async () => {
it('shows new running jobs', () => {
backgroundJobsMock.value = [job];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
expect(wrapper.get('[data-testid="agent-background-jobs"] button').text()).not.toContain(
'Stopped',
);
expect(wrapper.get('[data-testid="agent-background-jobs"] button').text()).toContain(
'Running',
);
wrapper.unmount();
});
it('keeps Stop available and focused while a workflow remains', async () => {
const workflow: AgentBackgroundJobDto = { ...job, id: 'workflow', kind: 'workflow' };
const completed: AgentBackgroundJobDto = { ...job, status: 'completed' };
backgroundJobsMock.value = [completed, workflow];
@ -1663,32 +1994,32 @@ describe('AgentChatPanel', () => {
}
});
it.each(['subagent', 'workflow'] as const)(
'stops a %s group and keeps failures retryable',
async (kind) => {
backgroundJobsMock.value = [{ ...job, kind }];
const wrapper = mountPanel({ backgroundJobsActive: true }, document.body);
await wrapper.get('[data-testid="agent-background-jobs"] button').trigger('click');
const stop = wrapper.get('button[data-testid="agent-background-jobs-stop"]');
expect(stop.text()).toBe('Stop all tasks');
isStoppingMock.value = true;
await nextTick();
expect(stop.attributes('disabled')).toBeDefined();
expect(wrapper.text()).toContain('Stopping 1 background task…');
expect(wrapper.get('[aria-label="Stopping…"]').isVisible()).toBe(true);
isStoppingMock.value = false;
const error = new Error('Request failed');
stopAllMock.mockRejectedValueOnce(error);
await nextTick();
await stop.trigger('click');
await flushPromises();
expect(showErrorMock).toHaveBeenCalledWith(error, 'agents.chat.backgroundTasks.stopError');
expect(stop.attributes('disabled')).toBeUndefined();
expect(wrapper.text()).toContain(job.title);
expect(wrapper.text()).not.toContain('Stopping');
wrapper.unmount();
},
);
it('stops sub-agents from an accessible button and keeps failures retryable', async () => {
backgroundJobsMock.value = [job];
const wrapper = mountPanel({ backgroundJobsActive: true }, document.body);
await wrapper.get('[data-testid="agent-background-jobs"] button').trigger('click');
const stop = wrapper.get('button[data-testid="agent-background-jobs-stop"]');
expect(stop.text()).toBe('Stop all tasks');
isStoppingMock.value = true;
await nextTick();
expect(stop.attributes('disabled')).toBeDefined();
expect(wrapper.text()).toContain('Stopping 1 background task…');
expect(wrapper.get('[aria-label="Stopping…"]').isVisible()).toBe(true);
isStoppingMock.value = false;
const error = new Error('Request failed');
stopAllMock.mockRejectedValueOnce(error);
await nextTick();
await stop.trigger('click');
await flushPromises();
expect(showErrorMock).toHaveBeenCalledWith(error, 'agents.chat.backgroundTasks.stopError');
expect(stop.attributes('disabled')).toBeUndefined();
expect(wrapper.text()).toContain(job.title);
expect(wrapper.text()).not.toContain('Stopping');
backgroundJobsMock.value = [{ ...job, kind: 'workflow' }];
await nextTick();
expect(wrapper.find('[data-testid="agent-background-jobs-stop"]').exists()).toBe(true);
wrapper.unmount();
});
it.each(['running', 'suspended'] as const)(
'keeps stopped rows visible while a %s sub-agent is still stopping',
@ -2053,20 +2384,27 @@ describe('AgentChatPanel', () => {
wrapper.unmount();
});
it('shows the background tasks section for n8n Chat but hides the trace link', async () => {
backgroundJobsMock.value = [job];
const wrapper = mountPanel({
backgroundJobsActive: true,
continueSessionId: 't1',
channel: 'n8n-chat',
});
const panel = wrapper.get('[data-testid="agent-background-jobs"]');
await panel.get('button').trigger('click');
// `/sessions/<threadId>` is the builder's session view, which needs
// `agent:read` — a chat-only n8n Chat member only has `agent:execute`.
expect(wrapper.find('[data-testid="agent-background-jobs-trace"]').exists()).toBe(false);
wrapper.unmount();
});
it.each([false, true])(
'keeps n8n Chat background controls without a trace link, plan=%s',
async (hasPlan) => {
if (hasPlan) messagesMock.value = [planMessage(planView())];
backgroundJobsMock.value = [job];
const wrapper = mountPanel({
backgroundJobsActive: true,
continueSessionId: 't1',
channel: 'n8n-chat',
});
const panel = wrapper.get('[data-testid="agent-background-jobs"]');
await panel.get('button').trigger('click');
// `/sessions/<threadId>` is the builder's session view, which needs
// `agent:read` — a chat-only n8n Chat member only has `agent:execute`.
expect(wrapper.find('[data-testid="agent-background-jobs-trace"]').exists()).toBe(false);
await wrapper.get('[data-testid="agent-background-jobs-stop"]').trigger('click');
expect(stopAllMock).toHaveBeenCalledOnce();
expect(stopPlanMock).not.toHaveBeenCalled();
wrapper.unmount();
},
);
it.each([
['completed', 'Completed', 'circle-check'],

View File

@ -28,6 +28,23 @@ describe('AgentChatPlan timer', () => {
vi.restoreAllMocks();
});
it('freezes the stopped timer across reloads and shows a failed report notice', async () => {
const wrapper = mount(AgentChatPlan, {
props: {
plan: planView({ startedAt }),
stopped: true,
stoppedAt: '2026-10-01T10:00:30.000Z',
reportFailed: true,
},
});
expect(wrapper.get('[data-testid="agent-chat-plan-timer"]').text()).toBe('0:30');
await vi.advanceTimersByTimeAsync(20_000);
expect(wrapper.get('[data-testid="agent-chat-plan-timer"]').text()).toBe('0:30');
await wrapper.get('button').trigger('click');
expect(wrapper.text()).toContain('agents.chat.tasks.reportFailed');
wrapper.unmount();
});
it('starts only with stored timing and keeps counting while the parent reviews completed work', async () => {
const wrapper = mount(AgentChatPlan, { props: { plan: planView({ startedAt: null }) } });
try {
@ -136,6 +153,38 @@ describe('AgentChatPlan timer', () => {
});
describe('AgentChatPlan', () => {
it.each([
{ status: 'pending', summary: '0 of 2 tasks done' },
{ status: 'done', summary: '1 of 2 tasks done' },
] as const)(
'counts an empty $status group once until it has subtasks',
async ({ status, summary }) => {
const group = { ...planTask(10, status), kind: 'group' as const, tasks: [] };
const plan = planView({
document: { title: 'Research options', items: [planTask(1), group] },
});
const wrapper = mount(AgentChatPlan, { props: { plan, expanded: true } });
try {
expect(wrapper.get('[data-testid="agent-chat-plan-summary"]').text()).toBe(summary);
await wrapper.setProps({
plan: {
...plan,
revision: 2,
document: {
...plan.document,
items: [planTask(1), { ...group, tasks: [planTask(2, 'done'), planTask(3)] }],
},
},
});
expect(wrapper.get('[data-testid="agent-chat-plan-summary"]').text()).toBe(
'1 of 3 tasks done',
);
} finally {
wrapper.unmount();
}
},
);
it('starts collapsed and supports keyboard expansion without changing focus', async () => {
const user = userEvent.setup();
const wrapper = mount(AgentChatPlan, { props: { plan: planView() }, attachTo: document.body });
@ -361,3 +410,89 @@ describe('AgentChatPlan', () => {
wrapper.unmount();
});
});
describe('Background task stop display', () => {
it('keeps saved task states and results through Stop and Continue', async () => {
const plan = planView({
document: {
title: 'Research leads',
presentation: { label: 'Researching leads', detail: 'Checking sources' },
items: [
{ ...planTask(1, 'done'), resultSummary: 'Found three leads' },
planTask(2, 'failed'),
planTask(3, 'cancelled'),
planTask(4, 'in_progress'),
{
...planTask(5, 'in_progress'),
kind: 'group',
tasks: [planTask(6), planTask(7, 'done')],
},
],
},
});
const saved = structuredClone(plan);
const wrapper = mount(AgentChatPlan, { props: { plan, canStop: true } });
await wrapper.get('button').trigger('click');
await wrapper.get('[data-testid="agent-chat-plan-stop"]').trigger('click');
expect(wrapper.emitted('stop')).toHaveLength(1);
await wrapper.setProps({ stopping: true });
expect(
wrapper.get('[data-testid="agent-chat-plan-stop"]').attributes('disabled'),
).toBeDefined();
expect(wrapper.get('button').text()).toContain('agents.chat.tasks.stopping');
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true');
expect(wrapper.text()).not.toContain('Checking sources');
await wrapper.setProps({ stopping: false, stopped: true, canStop: false });
expect(wrapper.get('button').text()).toContain('agents.chat.tasks.stopped');
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true');
expect(wrapper.findAll('[data-status]').map((row) => row.attributes('data-status'))).toEqual([
'done',
'failed',
'cancelled',
'stopped',
'stopped',
'stopped',
'done',
]);
for (const row of wrapper.findAll('[data-status="stopped"]')) {
expect(row.attributes('aria-label')).toBe('agents.chat.tasks.stopped');
expect(row.getComponent(N8nIcon).props('icon')).toBe('circle-pause');
expect(row.getComponent(N8nIcon).props('spin')).toBe(false);
}
expect(wrapper.findAllComponents(N8nIcon)[0].props('spin')).toBe(false);
expect(wrapper.get('[data-testid="agent-chat-plan-summary"]').text()).toBe('2 of 6 tasks done');
expect(wrapper.find('[data-testid="agent-chat-plan-stop"]').exists()).toBe(false);
expect(plan).toEqual(saved);
await wrapper.setProps({ stopped: false, canStop: true });
expect(wrapper.findAll('[data-status]').map((row) => row.attributes('data-status'))).toEqual([
'done',
'failed',
'cancelled',
'in_progress',
'in_progress',
'pending',
'done',
]);
expect(wrapper.get('button').text()).toContain('Researching leads');
expect(wrapper.text()).toContain('Checking sources');
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true');
expect(plan).toEqual(saved);
wrapper.unmount();
});
it('offers a retry after a failed stop', async () => {
const wrapper = mount(AgentChatPlan, {
props: { plan: planView(), canStop: true, stopFailed: true },
});
await wrapper.get('button').trigger('click');
expect(wrapper.get('button').text()).toContain('agents.chat.tasks.stopFailedTitle');
expect(wrapper.get('[data-testid="agent-chat-plan-stop"]').text()).toBe(
'agents.chat.tasks.retry',
);
expect(wrapper.find('[data-status="stopped"]').exists()).toBe(false);
await wrapper.get('[data-testid="agent-chat-plan-stop"]').trigger('click');
expect(wrapper.emitted('stop')).toHaveLength(1);
wrapper.unmount();
});
});

View File

@ -99,6 +99,7 @@ vi.mock('@/features/credentials/credentials.store', () => ({
vi.mock('@n8n/i18n', () => {
const i18n = {
translations: {
'agentSessions.status.cancelled': 'Canceled',
'agents.chat.plan.error.rejected':
'The plan change was rejected. Expand this step for details.',
'agents.chat.toolNames.webSearch': 'Web search',
@ -161,6 +162,35 @@ function mountSteps(
}
describe('AgentChatToolSteps', () => {
it.each([
{ state: TOOL_CALL_STATE.CANCELLED, canceled: true, output: 'Tool call canceled' },
{
state: TOOL_CALL_STATE.ERROR,
output: 'Error: Tool call stopped by guardrail: tasks-cancelled',
},
])('shows canceled work without an error or repair action: $state', (cancellation) => {
const wrapper = mountSteps(
[{ tool: 'update_plan', toolCallId: 'tc-canceled', ...cancellation }],
{ canFixWithAssistant: true, executionId: 'execution-1' },
);
expect(wrapper.get('[data-testid="tool-step-summary"]').text()).toBe('Canceled');
expect(wrapper.find('[data-testid="tool-step-warning"]').exists()).toBe(false);
expect(wrapper.find('[data-test-id="tool-step-error"]').exists()).toBe(false);
expect(wrapper.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe(
false,
);
});
it('keeps other guardrail refusals visible as tool errors', () => {
const error = 'Error: Tool call stopped by guardrail: tool.blocked';
const wrapper = mountSteps(
[{ tool: 'lookup', toolCallId: 'tc-error', state: TOOL_CALL_STATE.ERROR, output: error }],
{ canFixWithAssistant: true, executionId: 'execution-1' },
);
expect(wrapper.get('[data-testid="tool-step-warning"]').attributes('title')).toBe(error);
expect(wrapper.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe(true);
});
it.each([
{
tool: 'update_plan',

View File

@ -307,6 +307,7 @@ describe('useAgentApi', () => {
restApiContext,
'DELETE',
'/projects/project%2F1/agents/v2/agent%2F1/chat/agent%3Achat%231/executions/execution%2F1',
{ scope: 'foreground' },
);
});

View File

@ -174,6 +174,29 @@ describe('useAgentBackgroundJobs', () => {
expect(jobs.value).toEqual([]);
});
it.each(['chat', 'n8n-chat'] as const)(
'retains a stopped workflow only in Preview after its report arrives, channel=%s',
async (channel) => {
const stopped: AgentBackgroundJobDto = {
...job,
kind: 'workflow',
status: 'cancelled',
pauseRequested: true,
};
vi.mocked(getAgentBackgroundJobs).mockResolvedValue({ tasks: [stopped] });
receivedJobs.value = [
{ id: job.id, title: job.title, kind: 'workflow', status: 'cancelled' },
];
const { jobs } = create(channel);
await flushPromises();
expect(jobs.value).toEqual(channel === 'chat' ? [stopped] : []);
vi.mocked(getAgentBackgroundJobs).mockResolvedValue({ tasks: [{ ...job, id: 'new-job' }] });
onEvent(update);
await flushPromises();
expect(jobs.value).toEqual([{ ...job, id: 'new-job' }]);
},
);
it('does not apply a stop response to another conversation', async () => {
const { jobs, stopAll, isStopping } = create();
await flushPromises();

View File

@ -0,0 +1,115 @@
import type { AgentTaskCancellationState, PushMessage } from '@n8n/api-types';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { flushPromises } from '@vue/test-utils';
import { effectScope, reactive, ref, type EffectScope } from 'vue';
import { useAgentTaskCancellation } from '../composables/useAgentTaskCancellation';
import { cancelAgentTasks, getAgentTaskCancellation } from '../composables/useAgentApi';
vi.mock('../composables/useAgentApi', () => ({
cancelAgentTasks: vi.fn(),
getAgentTaskCancellation: vi.fn(),
}));
vi.mock('@n8n/stores/useRootStore', () => ({ useRootStore: () => ({ restApiContext: {} }) }));
vi.mock('@/app/stores/pushConnection.store', () => ({ usePushConnectionStore: () => push }));
const push = reactive({
isConnected: true,
addEventListener: (callback: (event: PushMessage) => void) => {
onEvent = callback;
return vi.fn();
},
});
let onEvent: (event: PushMessage) => void;
const stopped: AgentTaskCancellationState = {
planId: null,
status: 'stopped',
requestedAt: '2026-10-01T10:00:00.000Z',
failures: [],
summary: { completed: 1, canceled: 2 },
plan: null,
heldQueueIds: ['1'],
};
const event: PushMessage = {
type: 'agentBackgroundTasksUpdated',
data: { projectId: 'project', agentId: 'agent', threadId: 'thread' },
};
let scope: EffectScope;
const thread = ref('thread');
function create() {
scope = effectScope();
return scope.run(() =>
useAgentTaskCancellation({
projectId: () => 'project',
agentId: () => 'agent',
threadId: () => thread.value,
active: () => true,
planId: () => null,
}),
)!;
}
beforeEach(() => {
vi.resetAllMocks();
thread.value = 'thread';
vi.mocked(getAgentTaskCancellation).mockResolvedValue(null);
});
afterEach(() => scope.stop());
it('ignores a stale read after a cancellation response', async () => {
const read = createDeferredPromise<AgentTaskCancellationState | null>();
vi.mocked(getAgentTaskCancellation).mockReturnValueOnce(read.promise);
const state = create();
vi.mocked(cancelAgentTasks).mockResolvedValue(stopped);
vi.mocked(getAgentTaskCancellation).mockResolvedValue(stopped);
await state.stopAll();
read.resolve(null);
await flushPromises();
expect(state.state.value).toEqual(stopped);
});
it('disables repeated clicks and sends a fresh Stop without a cancellation identity', async () => {
const response = createDeferredPromise<AgentTaskCancellationState>();
const failed: AgentTaskCancellationState = {
...stopped,
status: 'failed',
failures: [{ jobId: 'job', title: 'Workflow' }],
};
vi.mocked(getAgentTaskCancellation).mockResolvedValue(failed);
const state = create();
await flushPromises();
vi.mocked(cancelAgentTasks).mockReturnValue(response.promise);
const pending = state.stopAll();
await state.stopAll();
expect(state.isStopping.value).toBe(true);
expect(cancelAgentTasks).toHaveBeenCalledExactlyOnceWith({}, 'project', 'agent', 'thread', {
planId: null,
});
vi.mocked(getAgentTaskCancellation).mockResolvedValue(stopped);
response.resolve(stopped);
await pending;
expect(state.isStopping.value).toBe(false);
});
it('restores cancellation from server state and refreshes on push', async () => {
vi.mocked(getAgentTaskCancellation).mockResolvedValue(stopped);
const state = create();
await flushPromises();
expect(state.state.value?.heldQueueIds).toEqual(['1']);
vi.mocked(getAgentTaskCancellation).mockResolvedValue({ ...stopped, heldQueueIds: [] });
onEvent(event);
await flushPromises();
expect(state.state.value?.heldQueueIds).toEqual([]);
});
it('does not apply an old cancellation response to another conversation', async () => {
const response = createDeferredPromise<AgentTaskCancellationState>();
const state = create();
await flushPromises();
vi.mocked(cancelAgentTasks).mockReturnValue(response.promise);
const pending = state.stopAll();
thread.value = 'new-thread';
await flushPromises();
response.resolve(stopped);
await pending;
expect(state.state.value).toBeNull();
expect(state.isStopping.value).toBe(false);
});

View File

@ -292,6 +292,33 @@ and output in the collapsed details and the trace. Keep earlier failed calls
visible after a successful retry. Unexpected failures keep the existing error
treatment.
## Plan progress
Count each standalone task and empty group as one task. Use the empty group's
status to count completion. When a group has subtasks, count those subtasks
instead of the group.
## Stop tasks in Agent Preview
Place **Stop all tasks** beside **View trace** in the expanded plan card.
Use the same action in the background task card when there is no plan.
Disable the action during the request and show **Stopping tasks…**.
Keep the user's expansion choice. Restore focus when the action disappears.
Keep the action available while the plan has unfinished work. This includes
planning and result review between tasks. Hide it after the plan stops or ends.
Stop the current plan work. Show **Stopping tasks…** until that work settles.
Then show **Stopped** on the card and on unfinished task rows. Keep completed,
failed, and canceled task states. Keep saved results and the trace. Do not
change the saved plan states. Restore their display when the user continues.
Treat a requested stop as cancellation. Do not show its abort event as an error.
Finish with one brief acknowledgement. Do not show a background results entry
for this reply. Hide empty results entries in saved chats. Disable tools for this
reply. Keep Continue available. Composer Stop controls only the current response,
including the acknowledgement. If a stop request fails, keep the action available
for a retry. Do not show **Stopped** while the plan work is active.
## Extend this document
Add a section when an Agent-specific pattern applies to two or more Agent

View File

@ -18,6 +18,7 @@ import {
N8nCallout,
N8nIcon,
N8nIconButton,
N8nInput,
N8nLink,
N8nText,
N8nTooltip,
@ -86,6 +87,7 @@ import {
type BudgetAmountField,
} from '../utils/budget-config';
import { TIME } from '@/app/constants/durations';
import { useAgentTaskCancellation } from '../composables/useAgentTaskCancellation';
import { useAgentBackgroundJobs } from '../composables/useAgentBackgroundJobs';
import { getChatAttachmentUrl, type AgentChatChannel } from '../composables/useAgentApi';
import { AGENT_ATTACHMENT_URL_KEY } from './agentChatInjectionKeys';
@ -232,6 +234,8 @@ const {
steeringQueueIds,
canSteer,
steerQueuedMessage,
sendHeldMessage,
updateQueuedMessage,
removeQueuedMessage,
reorderQueuedMessage,
isReorderingQueue,
@ -274,9 +278,82 @@ provide(
computed(() => props.interactionExtensions),
);
const currentPlan = computed(() => selectLatestAgentPlan(messages.value));
const planStopActive = computed(() => props.channel === 'chat' && props.backgroundJobsActive);
const messagePlan = computed(() => selectLatestAgentPlan(messages.value));
const {
state: planStop,
isStopping: stoppingPlan,
stopAll: stopPlan,
} = useAgentTaskCancellation({
projectId: () => props.projectId,
agentId: () => props.agentId,
threadId: () => props.continueSessionId,
active: () => planStopActive.value,
planId: () => currentPlan.value?.planId ?? null,
});
const currentPlan = computed(() => {
const saved = planStopActive.value ? planStop.value?.plan : undefined;
const displayed = messagePlan.value;
return saved &&
(!displayed || (saved.planId === displayed.planId && saved.revision >= displayed.revision))
? saved
: displayed;
});
const currentPlanStop = computed(() =>
planStopActive.value && planStop.value?.planId === currentPlan.value?.planId
? planStop.value
: null,
);
const planExpanded = ref(false);
watch(
[
() => props.projectId,
() => props.agentId,
() => props.continueSessionId,
() => currentPlan.value?.planId,
],
() => {
planExpanded.value = false;
},
);
const canStopPlan = computed(
() =>
planStopActive.value &&
!planStopped.value &&
(canStopBackgroundJobs.value ||
(!currentPlan.value?.closed &&
currentPlan.value?.document.items.some(
(item) =>
item.status === 'pending' ||
item.status === 'in_progress' ||
(item.kind === 'group' &&
item.tasks.some(
(task) => task.status === 'pending' || task.status === 'in_progress',
)),
))),
);
const showPlanStop = computed(
() => planStopActive.value && (canStopPlan.value || tasksStopping.value),
);
const editingQueueId = ref<string>();
const heldQueueEdit = ref<{ id: string; text: string; saving: boolean }>();
function isHeldQueueItem(id: string) {
return queuedMessages.value.some((item) => item.id === id && item.held);
}
async function saveHeldQueueEdit() {
const edit = heldQueueEdit.value;
if (!edit || edit.saving) return;
edit.saving = true;
try {
const result = await updateQueuedMessage(edit.id, edit.text);
if (heldQueueEdit.value === edit && result === 'updated') heldQueueEdit.value = undefined;
} finally {
edit.saving = false;
}
}
provide(AGENT_ATTACHMENT_URL_KEY, (attachmentId) =>
getChatAttachmentUrl(
rootStore.restApiContext,
@ -343,6 +420,11 @@ async function loadChatAttachmentFile(attachment: ChatMessageAttachment): Promis
}
async function startQueueEdit(item: AgentChatQueueItem) {
if (item.held) {
if (isQueueItemBusy(item)) return;
heldQueueEdit.value = { id: item.id, text: item.message, saving: false };
return;
}
if (hasDraft.value || isQueueItemBusy(item) || isSubmissionBlocked.value) return;
queueExpanded.value = true;
const target = {
@ -483,29 +565,143 @@ const backgroundStoppingIds = computed(
),
);
async function stopBackgroundJobs(event: MouseEvent) {
const stopFailed = ref(false);
const tasksStopping = computed(
() =>
isStopping.value ||
stoppingPlan.value ||
currentPlanStop.value?.status === 'stopping' ||
backgroundStoppingIds.value.size > 0,
);
const canRetryStop = computed(
() =>
stopFailed.value ||
currentPlanStop.value?.status === 'failed' ||
(!isStopping.value &&
backgroundJobs.value.some(
(job) => job.kind === 'workflow' && job.status === 'running' && job.pauseRequested,
)),
);
const stopDisabled = computed(() =>
props.channel === 'n8n-chat'
? isStopping.value
: isStopping.value || stoppingPlan.value || (tasksStopping.value && !canRetryStop.value),
);
const backgroundStopLabel = computed(() =>
locale.baseText(
props.channel === 'n8n-chat'
? 'agents.chat.backgroundTasks.stopAll'
: stopDisabled.value
? 'agents.chat.tasks.stopping'
: canRetryStop.value
? 'agents.chat.tasks.retry'
: 'agents.chat.tasks.stopAll',
),
);
const tasksStopped = computed(() => {
if (!planStopActive.value) return false;
if (tasksStopping.value || canStopBackgroundJobs.value) return false;
if (backgroundJobs.value.some((job) => job.status === 'paused')) return true;
// A job that finishes after Continue supersedes an earlier workflow stop.
const latest = [...backgroundJobs.value].sort((a, b) =>
(b.settledAt ?? '').localeCompare(a.settledAt ?? ''),
)[0];
return Boolean(latest?.pauseRequested);
});
const planStopped = computed(() => {
if (currentPlanStop.value) return currentPlanStop.value.status === 'stopped';
if (!planStopActive.value || !tasksStopped.value || currentPlan.value?.closed) return false;
const startedAt = Date.parse(currentPlan.value?.startedAt ?? '');
// A stop that settled before this plan started must not change its display.
return backgroundJobs.value.some(
(job) =>
(job.status === 'paused' || job.pauseRequested) &&
(!Number.isFinite(startedAt) || Date.parse(job.settledAt ?? '') >= startedAt),
);
});
watch(
[
() => props.projectId,
() => props.agentId,
() => props.continueSessionId,
canStopBackgroundJobs,
],
() => {
stopFailed.value = false;
},
);
async function sendHeldQueueMessage(queueId: string, event: MouseEvent) {
const button = event.currentTarget;
const restoreFocus = button === document.activeElement;
const restore = button === document.activeElement;
const threadId = props.continueSessionId;
try {
await stopAll();
} catch (error) {
toast.showError(error, locale.baseText('agents.chat.backgroundTasks.stopError'));
}
// Disabling the button can clear focus before the task rows disappear.
await sendHeldMessage(queueId);
await nextTick();
if (
restore &&
!disposed &&
props.visible &&
props.continueSessionId === threadId &&
(document.activeElement === document.body || document.activeElement === button)
)
focusInput({ preventScroll: true });
}
let taskStopFocus:
| { button: EventTarget | null; threadId?: string; projectId: string; agentId: string }
| undefined;
async function restoreTaskStopFocus() {
if (stopDisabled.value || !taskStopFocus) return;
const target = taskStopFocus;
taskStopFocus = undefined;
await nextTick();
if (
!restoreFocus ||
disposed ||
!props.visible ||
props.continueSessionId !== threadId ||
props.continueSessionId !== target.threadId ||
props.projectId !== target.projectId ||
props.agentId !== target.agentId ||
hasPendingApprovals.value ||
document.activeElement !== document.body
)
return;
if (button instanceof HTMLElement && button.isConnected) button.focus({ preventScroll: true });
if (
target.button instanceof HTMLElement &&
target.button.isConnected &&
!target.button.matches(':disabled')
)
target.button.focus({ preventScroll: true });
else focusInput({ preventScroll: true });
}
watch(stopDisabled, () => {
void restoreTaskStopFocus();
});
async function stopBackgroundJobs(event: MouseEvent) {
const stopCurrentPlan = currentPlan.value && planStopActive.value;
if (stopDisabled.value || !(stopCurrentPlan ? canStopPlan.value : canStopBackgroundJobs.value))
return;
stopFailed.value = false;
const threadId = props.continueSessionId;
if (event.currentTarget === document.activeElement) {
taskStopFocus = {
button: event.currentTarget,
threadId: props.continueSessionId,
projectId: props.projectId,
agentId: props.agentId,
};
}
try {
if (stopCurrentPlan) await stopPlan();
else await stopAll();
} catch (error) {
if (disposed || props.continueSessionId !== threadId) return;
stopFailed.value = true;
toast.showError(error, locale.baseText('agents.chat.backgroundTasks.stopError'));
}
await restoreTaskStopFocus();
}
const backgroundRunningCount = computed(
() => backgroundJobs.value.filter((job) => job.status === 'running').length,
);
@ -513,6 +709,7 @@ const backgroundInProgress = computed(
() => backgroundRunningCount.value > 0 || backgroundStoppingIds.value.size > 0,
);
const backgroundTitle = computed(() => {
if (tasksStopped.value) return locale.baseText('agents.chat.tasks.stopped');
const stoppingCount = backgroundStoppingIds.value.size;
if (stoppingCount > 0) {
return locale.baseText('agents.chat.backgroundTasks.stoppingCount', {
@ -697,7 +894,10 @@ const backgroundJobStopButton =
useTemplateRef<InstanceType<typeof N8nButton>>('backgroundJobStopButton');
const approvalCards = useTemplateRef<HTMLDivElement>('approvalCards');
const showBackgroundJobs = computed(
() => props.backgroundJobsActive && backgroundJobs.value.length > 0,
() =>
props.backgroundJobsActive &&
(props.channel !== 'chat' || !currentPlan.value) &&
backgroundJobs.value.length > 0,
);
function focusInput(options?: FocusOptions) {
@ -1079,6 +1279,7 @@ watch(
queuedExternalMessage = undefined;
handoffPreview = undefined;
editingQueueId.value = undefined;
heldQueueEdit.value = undefined;
queueExpanded.value = false;
queueOrder.value = undefined;
firstMessagePreview.value = undefined;
@ -1574,24 +1775,38 @@ onBeforeUnmount(() => {
</span>
</N8nLink>
<N8nButton
v-if="canStopBackgroundJobs"
v-if="canStopBackgroundJobs || tasksStopping"
ref="backgroundJobStopButton"
variant="ghost"
size="small"
:class="$style.jobStopButton"
:disabled="isStopping"
:disabled="stopDisabled"
data-testid="agent-background-jobs-stop"
@click="stopBackgroundJobs"
>
<span :class="$style.jobTraceLabel">
<N8nIcon icon="filled-square" size="small" aria-hidden="true" />
{{ locale.baseText('agents.chat.backgroundTasks.stopAll') }}
{{ backgroundStopLabel }}
</span>
</N8nButton>
</div>
</div>
</N8nAiActivityStepGroup>
</div>
<AgentChatPlan
v-if="hasPendingApprovals && currentPlan && planStopActive"
v-model:expanded="planExpanded"
:plan="currentPlan"
:trace-route="continueSessionId ? backgroundTraceRoute : undefined"
:show-stop="showPlanStop"
:can-stop="canStopPlan"
:stopping="planStopActive && stopDisabled"
:stopped="planStopped"
:stopped-at="currentPlanStop?.requestedAt"
:report-failed="currentPlanStop?.reportFailed"
:stop-failed="canRetryStop && canStopPlan && !isStopping"
@stop="stopBackgroundJobs"
/>
<div v-if="hasPendingApprovals" ref="approvalCards" data-testid="agent-chat-approvals">
<ApprovalCard
v-if="pendingApproval"
@ -1645,11 +1860,20 @@ onBeforeUnmount(() => {
<template v-if="currentPlan" #header>
<AgentChatPlan
v-if="currentPlan"
v-model:expanded="planExpanded"
:key="`${agentId}:${continueSessionId ?? ''}:${currentPlan.planId}`"
:plan="currentPlan"
:trace-route="
continueSessionId && capabilities.traceLinks ? backgroundTraceRoute : undefined
"
:show-stop="showPlanStop"
:can-stop="canStopPlan"
:stopping="planStopActive && stopDisabled"
:stopped="planStopped"
:stopped-at="currentPlanStop?.requestedAt"
:report-failed="currentPlanStop?.reportFailed"
:stop-failed="canRetryStop && canStopPlan && !isStopping"
@stop="stopBackgroundJobs"
/>
</template>
<template #above>
@ -1670,7 +1894,68 @@ onBeforeUnmount(() => {
@steer="steerQueuedMessage"
@edit="editQueuedMessage"
@remove="removeQueuedMessage"
/>
>
<template #message="{ item }">
<N8nInput
v-if="heldQueueEdit && heldQueueEdit.id === item.id"
v-model="heldQueueEdit.text"
type="textarea"
size="small"
:disabled="heldQueueEdit.saving"
:aria-label="locale.baseText('agents.chat.queue.edit')"
autofocus
@keydown.enter.exact.prevent="saveHeldQueueEdit"
@keydown.esc="heldQueueEdit = undefined"
/>
</template>
<template #actions="{ item }">
<template v-if="isHeldQueueItem(item.id)">
<template v-if="heldQueueEdit && heldQueueEdit.id === item.id">
<N8nButton
variant="ghost"
size="xsmall"
:disabled="heldQueueEdit.saving"
@click="saveHeldQueueEdit"
>{{ locale.baseText('generic.save') }}</N8nButton
>
<N8nButton
variant="ghost"
size="xsmall"
:disabled="heldQueueEdit.saving"
@click="heldQueueEdit = undefined"
>{{ locale.baseText('generic.cancel') }}</N8nButton
>
</template>
<template v-else>
<N8nButton
variant="ghost"
size="xsmall"
:disabled="isDisplayedQueueItemBusy(item) || isStopping"
@click="sendHeldQueueMessage(item.id, $event)"
>{{ locale.baseText('agents.chat.queue.send') }}</N8nButton
>
<N8nButton
variant="ghost"
size="xsmall"
icon="pencil"
icon-only
:aria-label="locale.baseText('generic.edit')"
:disabled="isDisplayedQueueItemBusy(item)"
@click="editQueuedMessage(item.id)"
/>
<N8nButton
variant="ghost"
size="xsmall"
icon="trash-2"
icon-only
:aria-label="locale.baseText('generic.delete')"
:disabled="isDisplayedQueueItemBusy(item)"
@click="removeQueuedMessage(item.id)"
/>
</template>
</template>
</template>
</ChatMessageQueue>
</template>
<template
v-if="
@ -1787,7 +2072,7 @@ onBeforeUnmount(() => {
--ai-activity-step--min-height: var(--height--xl);
--ai-activity-step--padding: var(--spacing--xs) var(--spacing--sm);
--ai-activity-step--color: var(--text-color);
display: none;
display: block;
background: var(--background--surface);
box-shadow: var(--shadow--outline), var(--shadow--xs);
border-radius: var(--radius--xs);

View File

@ -2,13 +2,31 @@
import { computed, ref, watch } from 'vue';
import { useDocumentVisibility, useIntervalFn } from '@vueuse/core';
import type { RouteLocationRaw } from 'vue-router';
import { N8nAiActivityStepGroup, N8nIcon, N8nLink } from '@n8n/design-system';
import { N8nAiActivityStepGroup, N8nIcon, N8nLink, N8nButton } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import type { AgentPlanItemStatus, AgentPlanView } from '../utils/agent-plan';
import { formatAgentElapsedTime } from '../utils/agent-elapsed-time';
import { TIME } from '@/app/constants/durations';
const props = defineProps<{ plan: AgentPlanView; traceRoute?: RouteLocationRaw }>();
const props = defineProps<{
plan: AgentPlanView;
traceRoute?: RouteLocationRaw;
showStop?: boolean;
canStop?: boolean;
stopping?: boolean;
stopped?: boolean;
stoppedAt?: string;
reportFailed?: boolean;
stopFailed?: boolean;
}>();
const expanded = defineModel<boolean>('expanded', { default: false });
watch(
() => props.plan.planId,
() => {
expanded.value = false;
},
);
const emit = defineEmits<{ stop: [event: MouseEvent] }>();
const i18n = useI18n();
const now = ref(Date.now());
const documentVisibility = useDocumentVisibility();
@ -24,6 +42,7 @@ watch(
() =>
Number.isFinite(startTime.value) &&
!props.plan.closed &&
!props.stopped &&
documentVisibility.value === 'visible',
(active) => {
if (active) {
@ -34,33 +53,46 @@ watch(
{ immediate: true },
);
const elapsed = computed(() => {
const endTime = props.plan.closed ? Date.parse(props.plan.closedAt ?? '') : now.value;
const endTime = props.plan.closed
? Date.parse(props.plan.closedAt ?? '')
: props.stopped
? Date.parse(props.stoppedAt ?? '') || now.value
: now.value;
if (!Number.isFinite(startTime.value) || !Number.isFinite(endTime)) return null;
return formatAgentElapsedTime(endTime - startTime.value);
});
const label = computed(() =>
props.plan.closed
const label = computed(() => {
if (props.stopping) return i18n.baseText('agents.chat.tasks.stopping');
if (props.stopFailed) return i18n.baseText('agents.chat.tasks.stopFailedTitle');
if (props.stopped) return i18n.baseText('agents.chat.tasks.stopped');
return props.plan.closed
? (props.plan.document.presentation?.detail ?? props.plan.document.title)
: (props.plan.document.presentation?.label ??
i18n.baseText('agents.chat.plan.title', {
interpolate: { title: props.plan.document.title },
})),
);
i18n.baseText('agents.chat.plan.title', {
interpolate: { title: props.plan.document.title },
}));
});
const isRunning = computed(
() =>
!props.plan.closed &&
!props.stopped &&
props.plan.document.items.some(
(item) =>
item.status === 'in_progress' ||
(item.kind === 'group' && item.tasks.some((task) => task.status === 'in_progress')),
),
);
const statusLabels: Record<AgentPlanItemStatus, BaseTextKey> = {
type DisplayStatus = AgentPlanItemStatus | 'stopped';
function displayStatus(status: AgentPlanItemStatus): DisplayStatus {
return props.stopped && (status === 'pending' || status === 'in_progress') ? 'stopped' : status;
}
const statusLabels: Record<DisplayStatus, BaseTextKey> = {
pending: 'agents.chat.plan.status.pending',
in_progress: 'agents.chat.plan.status.inProgress',
done: 'agents.chat.plan.status.done',
failed: 'agents.chat.plan.status.failed',
cancelled: 'agents.chat.plan.status.canceled',
stopped: 'agents.chat.tasks.stopped',
};
const statusIcons = {
pending: 'circle',
@ -68,10 +100,11 @@ const statusIcons = {
done: 'check',
failed: 'x',
cancelled: 'x',
stopped: 'circle-pause',
} as const;
const summary = computed(() => {
const tasks = props.plan.document.items.flatMap((item) =>
item.kind === 'group' ? item.tasks : [item],
item.kind === 'group' && item.tasks.length > 0 ? item.tasks : [item],
);
return i18n.baseText('agents.chat.plan.progress', {
adjustToNumber: tasks.length,
@ -86,15 +119,20 @@ const summary = computed(() => {
<template>
<div :class="$style.plan" data-testid="agent-chat-plan">
<N8nAiActivityStepGroup
v-model:open="expanded"
:key="plan.planId"
:label="label"
:title="plan.closed || plan.document.presentation ? label : plan.document.title"
:title="
stopping || stopped || stopFailed || plan.closed || plan.document.presentation
? label
: plan.document.title
"
full-width
content-position="below"
>
<template #prefix>
<N8nIcon
:icon="isRunning ? 'loader-circle' : 'list-checks'"
:icon="stopped ? 'circle-pause' : isRunning ? 'loader-circle' : 'list-checks'"
:spin="isRunning"
:class="{ [$style.running]: isRunning }"
size="small"
@ -123,44 +161,58 @@ const summary = computed(() => {
<div :class="[$style.row, { [$style.group]: item.kind === 'group' }]">
<span
role="img"
:aria-label="i18n.baseText(statusLabels[item.status])"
:title="i18n.baseText(statusLabels[item.status])"
:data-status="item.status"
:aria-label="i18n.baseText(statusLabels[displayStatus(item.status)])"
:title="i18n.baseText(statusLabels[displayStatus(item.status)])"
:data-status="displayStatus(item.status)"
:class="$style.status"
>
<N8nIcon
:icon="statusIcons[item.status]"
:spin="item.status === 'in_progress'"
:icon="statusIcons[displayStatus(item.status)]"
:spin="displayStatus(item.status) === 'in_progress'"
size="small"
aria-hidden="true"
/>
</span>
<span :class="$style.itemTitle" :title="item.title">{{ item.title }}</span>
<span
v-if="displayStatus(item.status) === 'stopped'"
:class="$style.stoppedLabel"
aria-hidden="true"
>
{{ i18n.baseText('agents.chat.tasks.stopped') }}
</span>
</div>
<ul v-if="item.kind === 'group'" :class="$style.children">
<li v-for="task in item.tasks" :key="task.id" :class="$style.row">
<span
role="img"
:aria-label="i18n.baseText(statusLabels[task.status])"
:title="i18n.baseText(statusLabels[task.status])"
:data-status="task.status"
:aria-label="i18n.baseText(statusLabels[displayStatus(task.status)])"
:title="i18n.baseText(statusLabels[displayStatus(task.status)])"
:data-status="displayStatus(task.status)"
:class="$style.status"
>
<N8nIcon
:icon="statusIcons[task.status]"
:spin="task.status === 'in_progress'"
:icon="statusIcons[displayStatus(task.status)]"
:spin="displayStatus(task.status) === 'in_progress'"
size="small"
aria-hidden="true"
/>
</span>
<span :class="$style.itemTitle" :title="task.title">{{ task.title }}</span>
<span
v-if="displayStatus(task.status) === 'stopped'"
:class="$style.stoppedLabel"
aria-hidden="true"
>
{{ i18n.baseText('agents.chat.tasks.stopped') }}
</span>
</li>
</ul>
</li>
</ul>
</div>
<p
v-if="!plan.closed && plan.document.presentation?.detail"
v-if="!plan.closed && !stopping && !stopped && plan.document.presentation?.detail"
:class="$style.detail"
:title="plan.document.presentation.detail"
>
@ -180,8 +232,32 @@ const summary = computed(() => {
{{ i18n.baseText('agents.chat.plan.viewTrace') }}
</span>
</N8nLink>
<N8nButton
v-if="showStop || canStop || stopping"
variant="ghost"
size="small"
:disabled="stopping || !canStop"
data-testid="agent-chat-plan-stop"
@click="emit('stop', $event)"
>
{{
i18n.baseText(
stopping
? 'agents.chat.tasks.stopping'
: stopFailed
? 'agents.chat.tasks.retry'
: 'agents.chat.tasks.stopAll',
)
}}
</N8nButton>
<span :class="$style.summary" data-testid="agent-chat-plan-summary">{{ summary }}</span>
</div>
<p v-if="stopFailed" :class="$style.detail" role="status">
{{ i18n.baseText('agents.chat.backgroundTasks.stopError') }}
</p>
<p v-if="reportFailed" :class="$style.detail" role="status">
{{ i18n.baseText('agents.chat.tasks.reportFailed') }}
</p>
</N8nAiActivityStepGroup>
</div>
</template>
@ -252,7 +328,15 @@ const summary = computed(() => {
text-overflow: ellipsis;
}
.stoppedLabel {
margin-inline-start: auto;
flex-shrink: 0;
font-weight: var(--font-weight--regular);
color: var(--text-color--subtler);
}
.footer {
flex-wrap: wrap;
display: flex;
align-items: center;
gap: var(--spacing--xs);

View File

@ -57,6 +57,7 @@ const fixableFailures = computed<AgentFixWithAssistantFailure[]>(() => {
const failures: AgentFixWithAssistantFailure[] = [];
for (const toolCall of props.toolCalls) {
if (dismissed.has(toolCall.toolCallId)) continue;
if (isCanceledToolCall(toolCall)) continue;
if (toolCall.state !== TOOL_CALL_STATE.ERROR) continue;
if (isRecoverablePlanError(toolCall)) continue;
// The assistant cannot lift a policy.
@ -117,6 +118,7 @@ function toolStepLabel(tc: ToolCall, isCompact = false): string {
}
function toolStepMetadata(tc: ToolCall): string[] {
if (isCanceledToolCall(tc)) return [i18n.baseText('agentSessions.status.cancelled')];
if (isDelegateSubAgentTool(tc.tool)) {
return [
resolveSubAgentName(tc.input, subAgentNameById.value),
@ -183,7 +185,18 @@ function toolStepRefusal(tc: ToolCall) {
return parseToolPolicyRefusal(tc.output);
}
function isCanceledToolCall(tc: ToolCall): boolean {
return (
tc.canceled === true ||
tc.state === TOOL_CALL_STATE.CANCELLED ||
// Older saved cancellations used the tool-error path.
(tc.state === TOOL_CALL_STATE.ERROR &&
tc.output === 'Error: Tool call stopped by guardrail: tasks-cancelled')
);
}
function toolStepError(tc: ToolCall): string | undefined {
if (isCanceledToolCall(tc)) return undefined;
if (isRecoverablePlanError(tc)) return i18n.baseText('agents.chat.plan.error.rejected');
if (tc.state !== TOOL_CALL_STATE.ERROR) return undefined;
if (toolStepRefusal(tc)) {

View File

@ -1,3 +1,4 @@
import type { AgentTaskCancellationState, AgentTaskCancellationDto } from '@n8n/api-types';
import type {
AgentApproval,
AgentBudgetSpend,
@ -954,6 +955,7 @@ export const cancelAgentChatRun = async (
context,
'DELETE',
`${agentChatPath(projectId, agentId, channel)}/runs/${encodeURIComponent(runId)}`,
...(channel === 'chat' ? [{ scope: 'foreground' }] : []),
);
};
@ -969,6 +971,7 @@ export const cancelAgentChatExecution = async (
context,
'DELETE',
`${agentChatPath(projectId, agentId, channel)}/${encodeURIComponent(threadId)}/executions/${encodeURIComponent(executionId)}`,
...(channel === 'chat' ? [{ scope: 'foreground' }] : []),
);
};
@ -1020,3 +1023,42 @@ export const getAgentWriteLock = async (
`/projects/${projectId}/agents/v2/${agentId}/collaboration/write-lock`,
);
};
export const getAgentTaskCancellation = async (
context: IRestApiContext,
projectId: string,
agentId: string,
threadId: string,
): Promise<AgentTaskCancellationState | null> =>
await makeRestApiRequest(
context,
'GET',
`/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/task-cancellation`,
);
export const cancelAgentTasks = async (
context: IRestApiContext,
projectId: string,
agentId: string,
threadId: string,
payload: AgentTaskCancellationDto,
): Promise<AgentTaskCancellationState> =>
await makeRestApiRequest(
context,
'POST',
`/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/task-cancellation`,
payload,
);
export const sendHeldAgentMessage = async (
context: IRestApiContext,
projectId: string,
agentId: string,
threadId: string,
queueId: string,
): Promise<void> =>
await makeRestApiRequest(
context,
'POST',
`/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/queue/${encodeURIComponent(queueId)}/send`,
);

View File

@ -44,6 +44,11 @@ export function useAgentBackgroundJobs(target: BackgroundJobsTarget) {
}));
if (current.some((job) => job.status === 'running' || job.status === 'suspended'))
return current;
if (
toValue(target.channel) !== 'n8n-chat' &&
current.some((job) => job.status === 'paused' || job.pauseRequested)
)
return current;
return group.value.pendingTaskIds?.some((id) => !received.has(id)) ? current : [];
});
const active = computed(() => toValue(target.active) && visibility.value === 'visible');

View File

@ -1,5 +1,6 @@
import { ref, reactive, computed, watch, onScopeDispose, type Ref } from 'vue';
import { useDocumentVisibility } from '@vueuse/core';
import { sendHeldAgentMessage } from './useAgentApi';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { TIME } from '@/app/constants/durations';
import { useI18n } from '@n8n/i18n';
@ -454,6 +455,31 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
}
}
async function sendHeldMessage(queueId: string): Promise<void> {
const threadId = params.continueSessionId?.value ?? acceptedSessionId.value;
if (!threadId || steeringQueueIds.value.has(queueId)) return;
const target = targetKey();
steeringQueueIds.value.add(queueId);
try {
await sendHeldAgentMessage(
rootStore.restApiContext,
params.projectId.value,
params.agentId.value,
threadId,
queueId,
);
} catch (error) {
if (!disposed && target === targetKey())
showError(error, locale.baseText('agents.chat.queue.sendError'));
} finally {
if (!disposed && target === targetKey()) {
queueVersion++;
await refreshQueue();
steeringQueueIds.value.delete(queueId);
}
}
}
async function steerQueuedMessage(queueId: string): Promise<void> {
const threadId = params.continueSessionId?.value ?? acceptedSessionId.value;
const executionId = steerableExecutionId.value;
@ -1873,6 +1899,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
canSteer,
steeringQueueIds,
steerQueuedMessage,
sendHeldMessage,
removingQueueIds,
removeQueuedMessage,
updateQueuedMessage,

View File

@ -0,0 +1,101 @@
import type { AgentTaskCancellationState } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, onScopeDispose, ref, watch } from 'vue';
import { useDocumentVisibility } from '@vueuse/core';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { cancelAgentTasks, getAgentTaskCancellation } from './useAgentApi';
export function useAgentTaskCancellation(target: {
projectId: () => string;
agentId: () => string;
threadId: () => string | undefined;
active: () => boolean;
planId: () => string | null;
}) {
const root = useRootStore();
const push = usePushConnectionStore();
const visibility = useDocumentVisibility();
const state = ref<AgentTaskCancellationState | null>(null);
const sending = ref(false);
const isStopping = computed(() => sending.value);
let revision = 0;
let disposed = false;
async function refresh() {
const threadId = target.threadId();
if (
!threadId ||
!target.active() ||
visibility.value !== 'visible' ||
disposed ||
sending.value
)
return;
const current = ++revision;
try {
const result = await getAgentTaskCancellation(
root.restApiContext,
target.projectId(),
target.agentId(),
threadId,
);
if (!disposed && current === revision) state.value = result;
} catch {
// A new conversation has no persisted thread yet. Keep the last confirmed state.
}
}
async function stopAll() {
const threadId = target.threadId();
if (!threadId || isStopping.value) return;
sending.value = true;
const current = ++revision;
try {
const result = await cancelAgentTasks(
root.restApiContext,
target.projectId(),
target.agentId(),
threadId,
{
planId: target.planId(),
},
);
if (!disposed && current === revision) state.value = result;
} finally {
if (!disposed && threadId === target.threadId()) {
sending.value = false;
await refresh();
}
}
}
const unsubscribe = push.addEventListener((event) => {
if (
(event.type === 'agentBackgroundTasksUpdated' || event.type === 'agentExecutionUpdated') &&
event.data.threadId === target.threadId() &&
event.data.agentId === target.agentId() &&
event.data.projectId === target.projectId()
)
void refresh();
});
watch(
[target.projectId, target.agentId, target.threadId],
() => {
revision++;
state.value = null;
sending.value = false;
void refresh();
},
{ immediate: true, flush: 'sync' },
);
watch([target.active, visibility, () => push.isConnected], () => {
void refresh();
});
onScopeDispose(() => {
disposed = true;
revision++;
unsubscribe();
});
return { state, isStopping, stopAll, refresh };
}

View File

@ -22,7 +22,7 @@ const groupSchema = z.object({
kind: z.literal('group'),
tasks: z.array(taskSchema),
});
const planSchema = z.object({
export const planSchema = z.object({
planId: z.string().uuid(),
revision: z.number().int().positive(),
closed: z.boolean(),

View File

@ -238,11 +238,13 @@ export function buildDisplayGroups(messages: AgentsChatMessage[]): DisplayGroup[
for (const message of messages) {
if (message.role === CHAT_MESSAGE_ROLE.ASSISTANT && message.backgroundJobSignal) {
// Keep the signal key stable when the same turn gains text or tool calls.
groups.push({
kind: DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL,
id: `${message.executionId ?? message.id}:background-job-signal`,
signal: message.backgroundJobSignal,
});
if (message.backgroundJobSignal.tasks.length > 0) {
groups.push({
kind: DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL,
id: `${message.executionId ?? message.id}:background-job-signal`,
signal: message.backgroundJobSignal,
});
}
if (
!message.content &&
!message.toolCalls?.length &&

View File

@ -173,9 +173,11 @@ watch(
:class="$style.queueIcon"
/>
<div :class="$style.queuePreview" :title="item.message">
<N8nText v-if="item.message" bold step="xs" color="text-light">{{
item.message
}}</N8nText>
<slot name="message" :item="item"
><N8nText v-if="item.message" bold step="xs" color="text-light">{{
item.message
}}</N8nText></slot
>
</div>
<N8nHoverCard v-if="item.attachmentNames?.length" side="top">
<template #trigger>
@ -208,54 +210,56 @@ watch(
:tabindex="item.notice ? 0 : undefined"
role="group"
>
<N8nTooltip
v-if="steerAction && !item.notice"
:content="steerAction.tooltip"
:disabled="!canSteer || isQueueItemBusy(item)"
placement="left"
>
<N8nButton
variant="ghost"
size="xsmall"
:icon="steerAction.icon"
icon-size="medium"
<slot name="actions" :item="item"
><N8nTooltip
v-if="steerAction && !item.notice"
:content="steerAction.tooltip"
:disabled="!canSteer || isQueueItemBusy(item)"
:aria-label="steerAction.label"
@click="emit('steer', item.id)"
placement="left"
>
{{ steerAction.label }}
</N8nButton>
</N8nTooltip>
<N8nTooltip
:content="locale.baseText('generic.edit')"
:disabled="!canEdit || isQueueItemBusy(item)"
placement="left"
>
<N8nIconButton
icon="pencil"
variant="ghost"
size="xsmall"
icon-size="medium"
<N8nButton
variant="ghost"
size="xsmall"
:icon="steerAction.icon"
icon-size="medium"
:disabled="!canSteer || isQueueItemBusy(item)"
:aria-label="steerAction.label"
@click="emit('steer', item.id)"
>
{{ steerAction.label }}
</N8nButton>
</N8nTooltip>
<N8nTooltip
:content="locale.baseText('generic.edit')"
:disabled="!canEdit || isQueueItemBusy(item)"
:aria-label="locale.baseText('generic.edit')"
@click="emit('edit', item.id)"
/>
</N8nTooltip>
<N8nTooltip
:content="locale.baseText('generic.delete')"
:disabled="isQueueItemBusy(item)"
placement="left"
>
<N8nIconButton
icon="trash-2"
variant="ghost"
size="xsmall"
icon-size="medium"
placement="left"
>
<N8nIconButton
icon="pencil"
variant="ghost"
size="xsmall"
icon-size="medium"
:disabled="!canEdit || isQueueItemBusy(item)"
:aria-label="locale.baseText('generic.edit')"
@click="emit('edit', item.id)"
/>
</N8nTooltip>
<N8nTooltip
:content="locale.baseText('generic.delete')"
:disabled="isQueueItemBusy(item)"
:aria-label="locale.baseText('generic.delete')"
@click="emit('remove', item.id)"
/>
</N8nTooltip>
placement="left"
>
<N8nIconButton
icon="trash-2"
variant="ghost"
size="xsmall"
icon-size="medium"
:disabled="isQueueItemBusy(item)"
:aria-label="locale.baseText('generic.delete')"
@click="emit('remove', item.id)"
/>
</N8nTooltip>
</slot>
</div>
</N8nTooltip>
</li>