refactor(editor): remove held message controls from plan stops

This commit is contained in:
Eugene Molodkin 2026-10-09 14:21:18 +02:00
parent 83e956badb
commit 3c741a4a0b
No known key found for this signature in database
8 changed files with 54 additions and 248 deletions

View File

@ -8420,12 +8420,9 @@
"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.retry": "Retry stopping",
"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.queue.sendError": "Couldn’t send the message. Try again.",
"agents.chat.plan.title": "Plan: {title}",
"agents.chat.plan.progress": "{done} of {total} task done | {done} of {total} tasks done",
"agents.chat.plan.viewTrace": "View trace",

View File

@ -88,8 +88,6 @@ vi.mock('../composables/useAgentTaskCancellation', () => ({
}));
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,
@ -127,7 +125,6 @@ vi.mock('@n8n/i18n', () => {
'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`,
@ -339,8 +336,6 @@ vi.mock('../composables/useAgentChatStream', () => ({
steeringQueueIds: steeringQueueIdsMock,
canSteer: canSteerMock,
steerQueuedMessage: steerQueuedMessageMock,
sendHeldMessage: sendHeldMessageMock,
updateQueuedMessage: updateQueuedMessageMock,
removeQueuedMessage: removeQueuedMessageMock,
reorderQueuedMessage: reorderQueuedMessageMock,
isReorderingQueue: isReorderingQueueMock,
@ -1128,41 +1123,6 @@ 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: '' },
@ -1190,9 +1150,7 @@ describe('AgentChatPanel', () => {
status: 'stopped',
requestedAt: new Date().toISOString(),
failures: [],
summary: { completed: 0, canceled: 0 },
plan: null,
heldQueueIds: [],
};
isStoppingMock.value = false;
await flushPromises();
@ -1646,9 +1604,7 @@ describe('AgentChatPanel', () => {
status: 'stopping',
requestedAt: new Date().toISOString(),
failures: [],
summary: { completed: 0, canceled: 0 },
plan: null,
heldQueueIds: [],
};
await nextTick();
expect(card.props('stopping')).toBe(true);

View File

@ -25,9 +25,7 @@ const stopped: AgentTaskCancellationState = {
status: 'stopped',
requestedAt: '2026-10-01T10:00:00.000Z',
failures: [],
summary: { completed: 1, canceled: 2 },
plan: null,
heldQueueIds: ['1'],
};
const event: PushMessage = {
type: 'agentBackgroundTasksUpdated',
@ -93,11 +91,11 @@ 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: [] });
expect(state.state.value?.status).toBe('stopped');
vi.mocked(getAgentTaskCancellation).mockResolvedValue({ ...stopped, status: 'resumed' });
onEvent(event);
await flushPromises();
expect(state.state.value?.heldQueueIds).toEqual([]);
expect(state.state.value?.status).toBe('resumed');
});
it('does not apply an old cancellation response to another conversation', async () => {

View File

@ -318,6 +318,7 @@ for this reply. Hide empty results entries in saved chats. Disable tools for thi
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.
Discard queued messages when the user stops the plan.
## Extend this document

View File

@ -18,7 +18,6 @@ import {
N8nCallout,
N8nIcon,
N8nIconButton,
N8nInput,
N8nLink,
N8nText,
N8nTooltip,
@ -234,8 +233,6 @@ const {
steeringQueueIds,
canSteer,
steerQueuedMessage,
sendHeldMessage,
updateQueuedMessage,
removeQueuedMessage,
reorderQueuedMessage,
isReorderingQueue,
@ -338,22 +335,6 @@ const showPlanStop = computed(
);
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,
@ -420,11 +401,6 @@ 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 = {
@ -632,22 +608,6 @@ watch(
},
);
async function sendHeldQueueMessage(queueId: string, event: MouseEvent) {
const button = event.currentTarget;
const restore = button === document.activeElement;
const threadId = props.continueSessionId;
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;
@ -1279,7 +1239,6 @@ watch(
queuedExternalMessage = undefined;
handoffPreview = undefined;
editingQueueId.value = undefined;
heldQueueEdit.value = undefined;
queueExpanded.value = false;
queueOrder.value = undefined;
firstMessagePreview.value = undefined;
@ -1894,68 +1853,7 @@ 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="

View File

@ -1049,16 +1049,3 @@ export const cancelAgentTasks = async (
`/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/task-cancellation`,
payload,
);
export const sendHeldAgentMessage = async (
context: IRestApiContext,
projectId: string,
agentId: string,
threadId: string,
queueId: string,
): Promise<void> =>
await makeRestApiRequest(
context,
'POST',
`/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/queue/${encodeURIComponent(queueId)}/send`,
);

View File

@ -1,6 +1,5 @@
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';
@ -455,31 +454,6 @@ 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;
@ -1899,7 +1873,6 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
canSteer,
steeringQueueIds,
steerQueuedMessage,
sendHeldMessage,
removingQueueIds,
removeQueuedMessage,
updateQueuedMessage,

View File

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