mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-16 21:19:43 +02:00
feat(editor): Add plan stop and continue controls in Agent Preview (no-changelog)
This commit is contained in:
parent
f8b7f9ffc7
commit
83e956badb
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@ describe('useAgentApi', () => {
|
|||
restApiContext,
|
||||
'DELETE',
|
||||
'/projects/project%2F1/agents/v2/agent%2F1/chat/agent%3Achat%231/executions/execution%2F1',
|
||||
{ scope: 'foreground' },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user