diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index aab31aa8599..900173c0e04 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts index 7576c3e555b..cedb786eef5 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts @@ -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', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts index 1a941a89ef3..7dc09c5c212 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts @@ -77,8 +77,19 @@ const isCancellingMock = ref(false); const backgroundJobsMock = ref([]); const respondToApprovalMock = vi.fn(); const stopAllMock = vi.fn(); +const stopPlanMock = vi.fn(async () => await stopAllMock()); +const planStopState = ref(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; adjustToNumber?: number }, ) => { const translations: Record = { + '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('[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('[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/` 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/` 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'], diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPlan.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPlan.test.ts index 1953c122a32..8b064db6dfe 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPlan.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPlan.test.ts @@ -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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts index 44dc9184a6f..8f0673704e3 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts @@ -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', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts index cbd2d40c369..2df0093a6c4 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts @@ -307,6 +307,7 @@ describe('useAgentApi', () => { restApiContext, 'DELETE', '/projects/project%2F1/agents/v2/agent%2F1/chat/agent%3Achat%231/executions/execution%2F1', + { scope: 'foreground' }, ); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentBackgroundJobs.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentBackgroundJobs.test.ts index 9816f41155a..d14c88294dd 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentBackgroundJobs.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentBackgroundJobs.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentTaskCancellation.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentTaskCancellation.test.ts new file mode 100644 index 00000000000..671e5856b4a --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentTaskCancellation.test.ts @@ -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(); + 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(); + 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(); + 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); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/agents-design-language.md b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md index a9de68438e7..69379829a79 100644 --- a/packages/frontend/editor-ui/src/features/agents/agents-design-language.md +++ b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md @@ -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 diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue index 14cf6e7e33d..84c0f5c63f4 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue @@ -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(); +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>('backgroundJobStopButton'); const approvalCards = useTemplateRef('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(() => { +
{