mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
refactor(editor): remove held message controls from plan stops
This commit is contained in:
parent
83e956badb
commit
3c741a4a0b
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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="
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user