mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
refactor(editor): Improve agent message queue and steering controls (#40287)
Co-authored-by: Bob Hough <15361982+heymynameisrob@users.noreply.github.com> Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
This commit is contained in:
parent
d5132d21e0
commit
fb11cc482e
|
|
@ -57,20 +57,8 @@ $E: null;
|
|||
@include scroll-bar;
|
||||
scrollbar-color: transparent transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
scrollbar-color: var.$scrollbar-background-color transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var.$scrollbar-background-color;
|
||||
|
||||
&:hover {
|
||||
background-color: var.$scrollbar-hover-background-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8067,7 +8067,7 @@
|
|||
"agents.chat.loadHistory.error": "Could not load chat history",
|
||||
"agents.chat.clearHistory.error": "Could not clear chat history",
|
||||
"agents.chat.stop.error": "Couldn't stop the agent. Try again.",
|
||||
"agents.chat.queue.title": "{count} message up next | {count} messages up next",
|
||||
"chat.messageQueue.title": "{count} queued message | {count} queued messages",
|
||||
"agents.chat.queue.steer": "Steer",
|
||||
"agents.chat.queue.steerTooltip": "Send to current execution",
|
||||
"agents.chat.queue.steering": "Waiting for the next step",
|
||||
|
|
@ -8079,8 +8079,8 @@
|
|||
"agents.chat.queue.editSteeringUnavailable": "This message is waiting for the next step. Your changes weren't applied.",
|
||||
"agents.chat.queue.editError": "Couldn't save your changes. Try again.",
|
||||
"agents.chat.queue.remove": "Remove queued message",
|
||||
"agents.chat.queue.reorder": "Reorder queued message {position} of {count}",
|
||||
"agents.chat.queue.reorderTooltip": "Drag to reorder. Use the Up and Down arrow keys to move the message.",
|
||||
"chat.messageQueue.reorder": "Reorder queued message {position} of {count}",
|
||||
"chat.messageQueue.reorderTooltip": "Drag to reorder",
|
||||
"agents.chat.queue.reorderUnavailable": "The queue changed. Review the updated order and try again.",
|
||||
"agents.chat.queue.reorderError": "Couldn't change the queue order. Try again.",
|
||||
"agents.chat.queue.loadError": "Couldn't load queued messages. Reopen the chat to try again.",
|
||||
|
|
@ -8120,6 +8120,8 @@
|
|||
"agents.chat.clearHistory": "Clear chat history",
|
||||
"agents.chat.input.placeholder": "Type a message...",
|
||||
"agents.chat.input.placeholder.withAgent": "Message {agentName}…",
|
||||
"agents.chat.input.placeholder.withQueue.mac": "Press Option + ↑ to edit the last queued message",
|
||||
"agents.chat.input.placeholder.withQueue.other": "Press Alt + ↑ to edit the last queued message",
|
||||
"agents.chat.attachments.tooLarge": "{fileName} exceeds the {limit} MB attachment limit",
|
||||
"agents.chat.attachments.tooMany": "You can attach up to {limit} files per message",
|
||||
"agents.chat.attachments.unavailable": "No longer available",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import AgentChatPlan from '../components/AgentChatPlan.vue';
|
|||
import { planMessage, planView } from './fixtures/agent-plan';
|
||||
|
||||
type PanelVm = { sendMessageFromOutside: (message: string, files?: File[]) => void };
|
||||
const isMacOsMock = ref(false);
|
||||
vi.mock('@n8n/composables/useDeviceSupport', () => ({
|
||||
useDeviceSupport: () => ({ isMacOs: isMacOsMock.value }),
|
||||
}));
|
||||
|
||||
const sendMessageMock = vi.fn();
|
||||
const stopGeneratingMock = vi.fn();
|
||||
|
|
@ -47,7 +51,6 @@ const trackSubmittedMessageMock = vi.fn();
|
|||
const trackSentMessageToN8nChatAgentMock = vi.fn();
|
||||
const queuedMessagesMock = ref<AgentChatQueueItem[]>([]);
|
||||
const removeQueuedMessageMock = vi.fn();
|
||||
const updateQueuedMessageMock = vi.fn();
|
||||
const reorderQueuedMessageMock = vi.fn();
|
||||
const isReorderingQueueMock = ref(false);
|
||||
const steerQueuedMessageMock = vi.fn();
|
||||
|
|
@ -80,6 +83,10 @@ const defaultAgentConfig: AgentJsonConfig = {
|
|||
instructions: 'Help.',
|
||||
};
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({ restApiContext: { baseUrl: '/rest' } }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => {
|
||||
const baseText = (
|
||||
key: string,
|
||||
|
|
@ -87,7 +94,7 @@ vi.mock('@n8n/i18n', () => {
|
|||
) => {
|
||||
const translations: Record<string, string> = {
|
||||
'agents.chat.input.placeholder.withAgent': `Message ${options?.interpolate?.agentName}…`,
|
||||
'agents.chat.queue.title': `${options?.interpolate?.count} ${options?.adjustToNumber === 1 ? 'message' : 'messages'} up next`,
|
||||
'chat.messageQueue.title': `${options?.interpolate?.count} ${options?.adjustToNumber === 1 ? 'message' : 'messages'} up next`,
|
||||
'agents.chat.misconfigured.issuesPrefix': 'Check:',
|
||||
'agents.chat.misconfigured.missing.tools': 'Tool configuration',
|
||||
'agents.chat.misconfigured.missing.mcpServers': 'MCP server',
|
||||
|
|
@ -140,12 +147,14 @@ vi.mock('@n8n/design-system', async (importOriginal) => ({
|
|||
N8nCallout: { template: '<div><slot /><slot name="trailingContent" /></div>' },
|
||||
N8nCard: { template: '<div><slot /></div>' },
|
||||
N8nDropdownMenu: { template: '<div><slot name="trigger" /></div>' },
|
||||
N8nHoverCard: { template: '<div><slot name="trigger" /></div>' },
|
||||
N8nHeading: { template: '<div><slot /></div>' },
|
||||
N8nIcon: { name: 'N8nIcon', props: ['icon', 'spin'], template: '<i />' },
|
||||
N8nIconButton: {
|
||||
emits: ['click'],
|
||||
template: '<button v-bind="$attrs" @click="$emit(\'click\')" />',
|
||||
},
|
||||
N8nScrollArea: { template: '<div><slot /></div>' },
|
||||
N8nText: { template: '<span><slot /></span>' },
|
||||
N8nTooltip: { template: '<div><slot /></div>' },
|
||||
TOOLTIP_DELAY_MS: 500,
|
||||
|
|
@ -198,7 +207,7 @@ vi.mock('@/features/ai/shared/components/ChatInputBase.vue', async () => {
|
|||
default: defineComponent({
|
||||
name: 'ChatInputBase',
|
||||
template:
|
||||
'<form data-testid="chat-input-stub" @submit.prevent="$emit(\'submit\')"><slot name="header" /><textarea ref="input" /><slot name="footer-start" /></form>',
|
||||
'<form data-testid="chat-input-stub" @submit.prevent="$emit(\'submit\')"><slot name="header" /><slot name="above" /><textarea ref="input" /><slot name="footer-start" /></form>',
|
||||
props: [
|
||||
'modelValue',
|
||||
'placeholder',
|
||||
|
|
@ -256,7 +265,6 @@ vi.mock('../composables/useAgentChatStream', () => ({
|
|||
canSteer: canSteerMock,
|
||||
steerQueuedMessage: steerQueuedMessageMock,
|
||||
removeQueuedMessage: removeQueuedMessageMock,
|
||||
updateQueuedMessage: updateQueuedMessageMock,
|
||||
reorderQueuedMessage: reorderQueuedMessageMock,
|
||||
isReorderingQueue: isReorderingQueueMock,
|
||||
isCancelling: isCancellingMock,
|
||||
|
|
@ -298,7 +306,8 @@ vi.mock('../composables/agentTelemetry.utils', () => ({
|
|||
describe('AgentChatPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateQueuedMessageMock.mockReset().mockResolvedValue('updated');
|
||||
isMacOsMock.value = false;
|
||||
removeQueuedMessageMock.mockReset().mockResolvedValue('removed');
|
||||
reorderQueuedMessageMock.mockReset().mockResolvedValue(undefined);
|
||||
isReorderingQueueMock.value = false;
|
||||
messagesMock.value = [];
|
||||
|
|
@ -469,7 +478,7 @@ describe('AgentChatPanel', () => {
|
|||
];
|
||||
await flushPromises();
|
||||
|
||||
const rows = wrapper.findAll('[data-testid="agent-queued-message"]');
|
||||
const rows = wrapper.findAll('[data-testid="chat-queued-message"]');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].text()).toContain('second message');
|
||||
wrapper.unmount();
|
||||
|
|
@ -687,86 +696,30 @@ describe('AgentChatPanel', () => {
|
|||
isStreamingMock.value = true;
|
||||
const wrapper = mountPanel({ backgroundJobsActive: true });
|
||||
const composer = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
expect(composer.find('[data-testid="agent-message-queue"]').exists()).toBe(true);
|
||||
expect(composer.find('[data-testid="chat-message-queue"]').exists()).toBe(true);
|
||||
expect(composer.find('[data-testid="agent-background-jobs"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-message-queue"] [aria-expanded]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(wrapper.find('[data-testid="chat-message-queue"] [aria-expanded]').exists()).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('[data-testid="agent-queued-message"]')
|
||||
.findAll('[data-testid="chat-queued-message"]')
|
||||
.map((row) => row.get('[title]').text()),
|
||||
).toEqual(['Next message', 'notes.txt']);
|
||||
).toEqual(['Next message', '']);
|
||||
expect(wrapper.find('[aria-label="notes.txt"]').exists()).toBe(true);
|
||||
expect(wrapper.html().indexOf('agent-background-jobs')).toBeLessThan(
|
||||
wrapper.html().indexOf('agent-message-queue'),
|
||||
wrapper.html().indexOf('chat-message-queue'),
|
||||
);
|
||||
expect(messagesMock.value).toEqual([]);
|
||||
const removeButtons = wrapper.findAll('[aria-label="agents.chat.queue.remove"]');
|
||||
for (const button of removeButtons) {
|
||||
expect(button.findComponent({ name: 'N8nIcon' }).props('icon')).toBe('trash-2');
|
||||
}
|
||||
const removeButtons = wrapper.findAll('[aria-label="generic.delete"]');
|
||||
await removeButtons[1].trigger('click');
|
||||
expect(removeQueuedMessageMock).toHaveBeenCalledWith('2');
|
||||
queuedMessagesMock.value = [];
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-testid="agent-message-queue"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="chat-message-queue"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-background-jobs"]').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('collapses only the third and later pending messages as the queue grows and shrinks', async () => {
|
||||
const items = [1, 2, 3, 4].map((id) => ({
|
||||
id: String(id),
|
||||
steeringExecutionId: null,
|
||||
message: `Message ${id}`,
|
||||
createdAt: '2026-09-24T12:00:00.000Z',
|
||||
}));
|
||||
queuedMessagesMock.value = items.slice(0, 2);
|
||||
const wrapper = mountPanel();
|
||||
const queue = wrapper.get('[data-testid="agent-message-queue"]');
|
||||
expect(queue.find('[aria-expanded]').exists()).toBe(false);
|
||||
|
||||
queuedMessagesMock.value = items.slice(0, 3);
|
||||
await nextTick();
|
||||
const toggle = queue.get('button[aria-expanded]');
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false');
|
||||
expect(toggle.text()).toBe('1 message up next');
|
||||
expect(queue.findAll('li').map((row) => row.get('[title]').text())).toEqual([
|
||||
'Message 1',
|
||||
'Message 2',
|
||||
]);
|
||||
|
||||
queuedMessagesMock.value = items;
|
||||
await nextTick();
|
||||
expect(toggle.text()).toBe('2 messages up next');
|
||||
expect(queue.findAll('li').map((row) => row.get('[title]').text())).toEqual([
|
||||
'Message 1',
|
||||
'Message 2',
|
||||
]);
|
||||
await toggle.trigger('click');
|
||||
expect(queue.findAll('li').map((row) => row.get('[title]').text())).toEqual([
|
||||
'Message 1',
|
||||
'Message 2',
|
||||
'Message 3',
|
||||
'Message 4',
|
||||
]);
|
||||
await toggle.trigger('click');
|
||||
expect(queue.findAll('li').map((row) => row.get('[title]').text())).toEqual([
|
||||
'Message 1',
|
||||
'Message 2',
|
||||
]);
|
||||
|
||||
queuedMessagesMock.value = items.slice(1, 3);
|
||||
await nextTick();
|
||||
expect(queue.find('[aria-expanded]').exists()).toBe(false);
|
||||
expect(queue.findAll('li').map((row) => row.get('[title]').text())).toEqual([
|
||||
'Message 2',
|
||||
'Message 3',
|
||||
]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('drags messages across the collapsed boundary and supports keyboard moves from the handle', async () => {
|
||||
it('drags messages across the collapsed boundary', async () => {
|
||||
const items: AgentChatQueueItem[] = [1, 2, 3, 4].map((id) => ({
|
||||
id: String(id),
|
||||
message: `Message ${id}`,
|
||||
|
|
@ -777,11 +730,11 @@ describe('AgentChatPanel', () => {
|
|||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const wrapper = mountPanel({}, container);
|
||||
const queue = wrapper.get('[data-testid="agent-message-queue"]');
|
||||
const queue = wrapper.get('[data-testid="chat-message-queue"]');
|
||||
const draggable = wrapper.getComponent(Draggable);
|
||||
expect(
|
||||
queue
|
||||
.get('[data-queue-id="1"] [data-testid="agent-queue-drag-handle"]')
|
||||
.get('[data-queue-id="1"] [data-testid="chat-queue-drag-handle"]')
|
||||
.attributes('disabled'),
|
||||
).toBeDefined();
|
||||
const saved = createDeferredPromise<void>();
|
||||
|
|
@ -803,7 +756,7 @@ describe('AgentChatPanel', () => {
|
|||
draggable.vm.$emit('end', { oldIndex: 1, newIndex: 3 });
|
||||
await nextTick();
|
||||
expect(reorderQueuedMessageMock).toHaveBeenCalledWith('2', '4', ['2', '3', '4']);
|
||||
for (const button of queue.findAll('[data-testid="agent-queue-drag-handle"]')) {
|
||||
for (const button of queue.findAll('[data-testid="chat-queue-drag-handle"]')) {
|
||||
expect(button.attributes('disabled')).toBeDefined();
|
||||
}
|
||||
expect(queue.findAll('li').map((row) => row.attributes('data-queue-id'))).toEqual([
|
||||
|
|
@ -814,15 +767,9 @@ describe('AgentChatPanel', () => {
|
|||
]);
|
||||
saved.resolve();
|
||||
await flushPromises();
|
||||
const handle = queue.get('[data-queue-id="2"] [data-testid="agent-queue-drag-handle"]');
|
||||
expect(document.activeElement).toBe(handle.element);
|
||||
await handle.trigger('keydown', { key: 'ArrowDown' });
|
||||
await queue
|
||||
.get('[data-queue-id="3"] [data-testid="agent-queue-drag-handle"]')
|
||||
.trigger('keydown', { key: 'ArrowUp' });
|
||||
const handle = queue.get('[data-queue-id="2"] [data-testid="chat-queue-drag-handle"]');
|
||||
expect(document.activeElement).not.toBe(handle.element);
|
||||
expect(reorderQueuedMessageMock).toHaveBeenCalledTimes(1);
|
||||
await handle.trigger('keydown', { key: 'ArrowUp' });
|
||||
expect(reorderQueuedMessageMock).toHaveBeenLastCalledWith('2', '4', ['3', '4', '2']);
|
||||
expect(stopGeneratingMock).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
container.remove();
|
||||
|
|
@ -850,7 +797,162 @@ describe('AgentChatPanel', () => {
|
|||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('edits queued text inline while preserving attachments and the composer draft', async () => {
|
||||
it('moves a queued message to the composer after removal and sends it through the normal path', async () => {
|
||||
queuedMessagesMock.value = [
|
||||
{
|
||||
id: '1',
|
||||
steeringExecutionId: null,
|
||||
message: 'original',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
removeQueuedMessageMock.mockImplementationOnce(async () => {
|
||||
queuedMessagesMock.value = [];
|
||||
return 'removed';
|
||||
});
|
||||
const wrapper = mountPanel();
|
||||
const composer = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
await wrapper.get('[aria-label="generic.edit"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(removeQueuedMessageMock).toHaveBeenCalledExactlyOnceWith('1');
|
||||
expect(composer.props('modelValue')).toBe('original');
|
||||
expect(wrapper.find('[data-testid="chat-queued-message"]').exists()).toBe(false);
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
composer.vm.$emit('update:modelValue', 'updated');
|
||||
composer.vm.$emit('submit');
|
||||
await flushPromises();
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('updated', undefined, expect.any(Function));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'shows the OS shortcut while messages are queued (Mac: %s)',
|
||||
async (isMacOs) => {
|
||||
isMacOsMock.value = isMacOs;
|
||||
const wrapper = mountPanel();
|
||||
const composer = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
const defaultPlaceholder = composer.props('placeholder');
|
||||
queuedMessagesMock.value = [
|
||||
{
|
||||
id: '1',
|
||||
steeringExecutionId: null,
|
||||
message: 'queued',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
await nextTick();
|
||||
expect(composer.props('placeholder')).toBe(
|
||||
isMacOs
|
||||
? 'agents.chat.input.placeholder.withQueue.mac'
|
||||
: 'agents.chat.input.placeholder.withQueue.other',
|
||||
);
|
||||
queuedMessagesMock.value = [];
|
||||
await nextTick();
|
||||
expect(composer.props('placeholder')).toBe(defaultPlaceholder);
|
||||
wrapper.unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('edits the last queued message with Alt+ArrowUp from the composer', async () => {
|
||||
queuedMessagesMock.value = ['first', 'last'].map((message, index) => ({
|
||||
id: String(index + 1),
|
||||
steeringExecutionId: null,
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
const wrapper = mountPanel();
|
||||
await wrapper.get('textarea').trigger('keydown', { key: 'ArrowUp', altKey: true });
|
||||
await flushPromises();
|
||||
expect(removeQueuedMessageMock).toHaveBeenCalledExactlyOnceWith('2');
|
||||
expect(wrapper.findComponent({ name: 'ChatInputBase' }).props('modelValue')).toBe('last');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ key: 'ArrowUp' },
|
||||
{ key: 'ArrowUp', altKey: true, shiftKey: true },
|
||||
{ key: 'ArrowUp', altKey: true, ctrlKey: true },
|
||||
{ key: 'ArrowUp', altKey: true, metaKey: true },
|
||||
{ key: 'ArrowUp', altKey: true, isComposing: true },
|
||||
{ key: 'ArrowUp', altKey: true, repeat: true },
|
||||
{ key: 'ArrowDown', altKey: true },
|
||||
])('ignores other composer key combinations: %j', async (keys) => {
|
||||
queuedMessagesMock.value = [
|
||||
{
|
||||
id: '1',
|
||||
steeringExecutionId: null,
|
||||
message: 'original',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
const wrapper = mountPanel();
|
||||
await wrapper.get('textarea').trigger('keydown', keys);
|
||||
await flushPromises();
|
||||
expect(removeQueuedMessageMock).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not use the shortcut when the composer has a draft or the last item is busy', async () => {
|
||||
const item: AgentChatQueueItem = {
|
||||
id: '1',
|
||||
steeringExecutionId: null,
|
||||
message: 'original',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
queuedMessagesMock.value = [item];
|
||||
const wrapper = mountPanel();
|
||||
const composer = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
composer.vm.$emit('update:modelValue', 'draft');
|
||||
await nextTick();
|
||||
await wrapper.get('textarea').trigger('keydown', { key: 'ArrowUp', altKey: true });
|
||||
expect(removeQueuedMessageMock).not.toHaveBeenCalled();
|
||||
expect(composer.props('modelValue')).toBe('draft');
|
||||
composer.vm.$emit('update:modelValue', '');
|
||||
queuedMessagesMock.value = [{ ...item, steeringExecutionId: 'execution' }];
|
||||
await nextTick();
|
||||
await wrapper.get('textarea').trigger('keydown', { key: 'ArrowUp', altKey: true });
|
||||
expect(removeQueuedMessageMock).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps an existing composer draft and disables queue editing', async () => {
|
||||
queuedMessagesMock.value = [
|
||||
{
|
||||
id: '1',
|
||||
steeringExecutionId: null,
|
||||
message: 'original',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
const wrapper = mountPanel();
|
||||
const composer = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
composer.vm.$emit('update:modelValue', 'next draft');
|
||||
await nextTick();
|
||||
expect(wrapper.get('[aria-label="generic.edit"]').attributes('disabled')).toBeDefined();
|
||||
expect(composer.props('modelValue')).toBe('next draft');
|
||||
expect(removeQueuedMessageMock).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('forwards a steering action from the queue', async () => {
|
||||
queuedMessagesMock.value = [
|
||||
{
|
||||
id: '1',
|
||||
message: 'Next message',
|
||||
createdAt: '2026-09-24T12:00:00.000Z',
|
||||
steeringExecutionId: null,
|
||||
},
|
||||
];
|
||||
canSteerMock.value = true;
|
||||
const wrapper = mountPanel();
|
||||
|
||||
await wrapper.get('[aria-label="agents.chat.queue.steer"]').trigger('click');
|
||||
|
||||
expect(steerQueuedMessageMock).toHaveBeenCalledExactlyOnceWith('1');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('restores attachments before sending the edited message', async () => {
|
||||
queuedMessagesMock.value = [
|
||||
{
|
||||
id: '1',
|
||||
|
|
@ -860,129 +962,69 @@ describe('AgentChatPanel', () => {
|
|||
attachments: [{ id: 'file', fileName: 'notes.txt', mimeType: 'text/plain', sizeBytes: 5 }],
|
||||
},
|
||||
];
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('notes'));
|
||||
const wrapper = mountPanel();
|
||||
const composer = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
composer.vm.$emit('update:modelValue', 'next draft');
|
||||
await wrapper.get('[aria-label="agents.chat.queue.edit"]').trigger('click');
|
||||
let editor = wrapper.get('textarea[aria-label="agents.chat.queue.edit"]');
|
||||
await editor.setValue('changed');
|
||||
await editor.trigger('keydown', { key: 'Escape' });
|
||||
expect(wrapper.find('textarea[aria-label="agents.chat.queue.edit"]').exists()).toBe(false);
|
||||
expect(updateQueuedMessageMock).not.toHaveBeenCalled();
|
||||
await wrapper.get('[aria-label="agents.chat.queue.edit"]').trigger('click');
|
||||
editor = wrapper.get('textarea[aria-label="agents.chat.queue.edit"]');
|
||||
await editor.setValue('updated text');
|
||||
await editor.trigger('keydown', { key: 'Enter', shiftKey: true });
|
||||
expect(updateQueuedMessageMock).not.toHaveBeenCalled();
|
||||
await editor.trigger('keydown', { key: 'Enter', isComposing: true });
|
||||
expect(updateQueuedMessageMock).not.toHaveBeenCalled();
|
||||
await editor.trigger('keydown', { key: 'Enter' });
|
||||
await flushPromises();
|
||||
expect(updateQueuedMessageMock).toHaveBeenCalledExactlyOnceWith('1', 'updated text');
|
||||
expect(composer.props('modelValue')).toBe('next draft');
|
||||
expect(wrapper.get('[data-testid="agent-queued-message"]').text()).toContain('notes.txt');
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('steers an overflow message and restores its controls when a reservation returns to FIFO', async () => {
|
||||
const items: AgentChatQueueItem[] = ['B', 'D', 'C'].map((message, index) => ({
|
||||
id: String(index + 1),
|
||||
message,
|
||||
createdAt: '2026-09-24T12:00:00.000Z',
|
||||
steeringExecutionId: null,
|
||||
}));
|
||||
queuedMessagesMock.value = items;
|
||||
const wrapper = mountPanel();
|
||||
await wrapper.get('[data-testid="agent-message-queue"] button[aria-expanded]').trigger('click');
|
||||
const row = wrapper.findAll('[data-testid="agent-queued-message"]')[2];
|
||||
const steer = row.get('[aria-label="agents.chat.queue.steer"]');
|
||||
expect(steer.attributes('disabled')).toBeDefined();
|
||||
canSteerMock.value = true;
|
||||
await nextTick();
|
||||
expect(steer.attributes('disabled')).toBeUndefined();
|
||||
expect(row.html().indexOf('aria-label="agents.chat.queue.steer"')).toBeLessThan(
|
||||
row.html().indexOf('aria-label="agents.chat.queue.edit"'),
|
||||
);
|
||||
await steer.trigger('click');
|
||||
expect(steerQueuedMessageMock).toHaveBeenCalledExactlyOnceWith('3');
|
||||
queuedMessagesMock.value = items.map((item) =>
|
||||
item.id === '3' ? { ...item, steeringExecutionId: 'A' } : item,
|
||||
);
|
||||
await nextTick();
|
||||
expect(row.text()).toContain('agents.chat.queue.steering');
|
||||
for (const action of ['steer', 'edit', 'remove', 'reorder'])
|
||||
expect(
|
||||
row.get(`[aria-label="agents.chat.queue.${action}"]`).attributes('disabled'),
|
||||
).toBeDefined();
|
||||
queuedMessagesMock.value = items;
|
||||
await nextTick();
|
||||
expect(row.text()).not.toContain('agents.chat.queue.steering');
|
||||
expect(row.get('[aria-label="agents.chat.queue.edit"]').attributes('disabled')).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('preserves an edit draft while another client reserves and releases its message', async () => {
|
||||
const item: AgentChatQueueItem = {
|
||||
id: '1',
|
||||
message: 'C',
|
||||
createdAt: new Date().toISOString(),
|
||||
steeringExecutionId: null,
|
||||
};
|
||||
queuedMessagesMock.value = [item];
|
||||
const wrapper = mountPanel();
|
||||
await wrapper.get('[aria-label="agents.chat.queue.edit"]').trigger('click');
|
||||
const editor = wrapper.get('textarea[aria-label="agents.chat.queue.edit"]');
|
||||
await editor.setValue('unsaved draft');
|
||||
queuedMessagesMock.value = [{ ...item, steeringExecutionId: 'A' }];
|
||||
await nextTick();
|
||||
expect(editor.element).toHaveProperty('value', 'unsaved draft');
|
||||
expect(
|
||||
wrapper.get('[aria-label="agents.chat.queue.save"]').attributes('disabled'),
|
||||
).toBeDefined();
|
||||
expect(wrapper.text()).toContain('agents.chat.queue.editSteeringUnavailable');
|
||||
queuedMessagesMock.value = [item];
|
||||
await nextTick();
|
||||
expect(
|
||||
wrapper.get('[aria-label="agents.chat.queue.save"]').attributes('disabled'),
|
||||
).toBeUndefined();
|
||||
await editor.trigger('keydown', { key: 'Enter' });
|
||||
expect(updateQueuedMessageMock).toHaveBeenCalledWith('1', 'unsaved draft');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it.each(['notification', 'conflict'])(
|
||||
'preserves edits when a message starts before saving (%s)',
|
||||
async (source) => {
|
||||
queuedMessagesMock.value = [
|
||||
{
|
||||
id: '1',
|
||||
steeringExecutionId: null,
|
||||
message: 'original',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
const wrapper = mountPanel();
|
||||
await wrapper.get('[aria-label="agents.chat.queue.edit"]').trigger('click');
|
||||
const editor = wrapper.get('textarea[aria-label="agents.chat.queue.edit"]');
|
||||
await editor.setValue('unsaved text');
|
||||
if (source === 'notification') queuedMessagesMock.value = [];
|
||||
else {
|
||||
updateQueuedMessageMock.mockResolvedValueOnce('unavailable');
|
||||
await editor.trigger('keydown', { key: 'Enter' });
|
||||
}
|
||||
try {
|
||||
await wrapper.get('[aria-label="generic.edit"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(editor.element).toHaveProperty('value', 'unsaved text');
|
||||
expect(editor.attributes('readonly')).toBeDefined();
|
||||
expect(
|
||||
wrapper.get('[aria-label="agents.chat.queue.save"]').attributes('disabled'),
|
||||
).toBeDefined();
|
||||
expect(wrapper.text()).toContain('agents.chat.queue.editUnavailable');
|
||||
await wrapper.get('[aria-label="agents.chat.queue.cancelEdit"]').trigger('click');
|
||||
expect(wrapper.find('textarea[aria-label="agents.chat.queue.edit"]').exists()).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/rest/projects/p1/agents/v2/a1/chat/attachments/file',
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
wrapper.findComponent({ name: 'ChatInputBase' }).vm.$emit('submit');
|
||||
await flushPromises();
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
'original',
|
||||
[expect.any(File)],
|
||||
expect.any(Function),
|
||||
);
|
||||
const files = sendMessageMock.mock.calls[0][1];
|
||||
expect(files[0].name).toBe('notes.txt');
|
||||
expect(files[0].type).toBe('text/plain');
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
wrapper.unmount();
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not restore a draft if queue removal fails', async () => {
|
||||
queuedMessagesMock.value = [
|
||||
{
|
||||
id: '1',
|
||||
steeringExecutionId: null,
|
||||
message: 'original',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
removeQueuedMessageMock.mockResolvedValueOnce('failed');
|
||||
const wrapper = mountPanel();
|
||||
await wrapper.get('[aria-label="generic.edit"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.findComponent({ name: 'ChatInputBase' }).props('modelValue')).toBe('');
|
||||
expect(wrapper.get('[data-testid="chat-queued-message"]').text()).toContain('original');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('ignores a completed removal after switching sessions', async () => {
|
||||
queuedMessagesMock.value = [
|
||||
{
|
||||
id: '1',
|
||||
steeringExecutionId: null,
|
||||
message: 'original',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
const removal = createDeferredPromise<string>();
|
||||
removeQueuedMessageMock.mockReturnValueOnce(removal.promise);
|
||||
const wrapper = mountPanel();
|
||||
await wrapper.get('[aria-label="generic.edit"]').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.setProps({ continueSessionId: 'another-session' });
|
||||
removal.resolve('removed');
|
||||
await flushPromises();
|
||||
expect(wrapper.findComponent({ name: 'ChatInputBase' }).props('modelValue')).toBe('');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
describe('plan card', () => {
|
||||
it('renders live plan results above the input without changing submission', async () => {
|
||||
|
|
@ -2790,6 +2832,27 @@ describe('AgentPreviewDock stream lifecycle', () => {
|
|||
);
|
||||
}
|
||||
|
||||
it('sends dock messages through the real chat path', async () => {
|
||||
queuedMessagesMock.value = [];
|
||||
sendMessageMock.mockResolvedValue('sent');
|
||||
const wrapper = mountPreviewDock();
|
||||
try {
|
||||
const composer = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
expect(wrapper.find('[data-testid="chat-message-queue"]').exists()).toBe(false);
|
||||
composer.vm.$emit('update:modelValue', 'Message from the dock');
|
||||
await nextTick();
|
||||
composer.vm.$emit('submit');
|
||||
await flushPromises();
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
'Message from the dock',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
);
|
||||
} finally {
|
||||
wrapper.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('detaches an in-flight stream when the preview starts a new session', async () => {
|
||||
const wrapper = mountPreviewDock();
|
||||
|
||||
|
|
|
|||
|
|
@ -269,17 +269,16 @@ the top of the composer. Use `--background--subtle` for the queue background.
|
|||
Use `--color--neutral-600` for queue text and the Steer label in light mode.
|
||||
Use `--text-color--subtler` in dark mode. Use `--color--neutral-400` for all queue
|
||||
icons. Use `--border-color--subtle` for the dividers.
|
||||
Use `2xs` text and `large` icons. Keep the action targets at least 24 by 24 pixels.
|
||||
Keep the first two messages visible.
|
||||
Put the third and later messages in a collapsed activity group. Show the number
|
||||
of additional pending messages in its header. Keep messages in queue order when
|
||||
expanded.
|
||||
Use `xs` text and `medium` icons. Keep the action targets at least 24 by 24 pixels.
|
||||
Show a single queued message without a toggle or drag handle.
|
||||
Collapse the full list when the queue has two or more messages. Show the total
|
||||
message count in the header. Keep messages in queue order when expanded.
|
||||
Keep pending messages out of the conversation until processing starts. Give each
|
||||
message a Remove action. Hide an empty queue section. Removal discards the
|
||||
message. It does not restore the composer draft.
|
||||
|
||||
Edit queued text in place. Use compact Save and Cancel icon actions. Enter saves, Shift+Enter adds a line, and Escape cancels. Keep attachments unchanged. Do not pause the queue during editing. If the message starts, disable Save and retain the draft until the user dismisses it.
|
||||
Edit removes the pending message from the queue and restores its text and attachments in the composer. Restore the draft only after removal succeeds. Disable Edit while the composer has a draft. Alt/Option+ArrowUp in the composer edits the last queued message. Ignore the shortcut if that message is busy or the composer has a draft. Send uses the normal message path and adds the message to the end of the queue if a turn is still running. Do not pause the queue.
|
||||
|
||||
Put a six-dot drag handle on the left of each pending message. Drag the handle to move the message. Support the Up and Down arrow keys on the handle. Expand the queue when a drag starts or a message moves. Keep keyboard focus on the moved message. Show the new order during saving. Disable queue actions while the order saves. Messages reserved for steering cannot move. If the move no longer applies, refresh the queue and show an error.
|
||||
Show drag handles only when the queue has two or more messages. Put a six-dot drag handle on the left of each pending message in that queue. Drag the handle to move the message. Support the Up and Down arrow keys on the handle. Expand the queue when a drag starts or a message moves. Keep keyboard focus on the moved message. Show the new order during saving. Disable queue actions while the order saves. Messages reserved for steering cannot move. If the move no longer applies, refresh the queue and show an error.
|
||||
|
||||
Put the action to send a message to the current execution immediately before Edit. Use the existing corner-down-right icon with the Steer label on its right. Use the existing button and tooltip. Enable it only when the server reports an eligible execution. Keep an accepted steering request in the panel with a waiting status. Disable its actions until the runtime consumes it or returns it to ordinary queue processing. Preserve an open edit draft if another client reserves the message, and disable Save. When the runtime consumes the message, show it between the surrounding assistant output. Keep Stop bound to the same execution.
|
||||
Put the action to send a message to the current execution immediately before Edit. Use the existing corner-down-right icon with the Steer label on its right. Use the existing button and tooltip. Enable it only when the server reports an eligible execution. Keep an accepted steering request in the panel with a waiting status. Disable its actions until the runtime consumes it or returns it to ordinary queue processing. When the runtime consumes the message, show it between the surrounding assistant output. Keep Stop bound to the same execution.
|
||||
|
|
|
|||
|
|
@ -10,17 +10,12 @@ import {
|
|||
onBeforeUnmount,
|
||||
useTemplateRef,
|
||||
nextTick,
|
||||
useId,
|
||||
} from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import {
|
||||
N8nAiActivityStepGroup,
|
||||
N8nAiActivityStepButton,
|
||||
N8nAiActivityStepChevron,
|
||||
N8nButton,
|
||||
N8nCallout,
|
||||
N8nIcon,
|
||||
N8nInput,
|
||||
N8nLink,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
|
|
@ -37,9 +32,15 @@ import {
|
|||
MAX_AGENT_CHAT_ATTACHMENTS_PER_MESSAGE,
|
||||
PROVIDER_CAPABILITIES,
|
||||
} from '@n8n/api-types';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||
import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
|
||||
import ChatMessageQueue from '@/features/ai/shared/components/ChatMessageQueue.vue';
|
||||
import type {
|
||||
ChatMessageQueueSteerAction,
|
||||
ChatMessageQueueItem,
|
||||
} from '@/features/ai/shared/components/chatMessageQueue.types';
|
||||
import AttachmentPreview from '@/features/ai/instanceAi/components/AttachmentPreview.vue';
|
||||
import { useAgentChatStream } from '../composables/useAgentChatStream';
|
||||
import {
|
||||
|
|
@ -135,6 +136,8 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const { isMacOs } = useDeviceSupport();
|
||||
const rootStore = useRootStore();
|
||||
const agentTelemetry = useAgentTelemetry();
|
||||
const toast = useToast();
|
||||
|
||||
|
|
@ -147,7 +150,6 @@ const {
|
|||
canSteer,
|
||||
steerQueuedMessage,
|
||||
removeQueuedMessage,
|
||||
updateQueuedMessage,
|
||||
reorderQueuedMessage,
|
||||
isReorderingQueue,
|
||||
isSubmitting,
|
||||
|
|
@ -184,7 +186,7 @@ const {
|
|||
|
||||
const currentPlan = computed(() => selectLatestAgentPlan(messages.value));
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const editingQueueId = ref<string>();
|
||||
provide(AGENT_ATTACHMENT_URL_KEY, (attachmentId) =>
|
||||
getChatAttachmentUrl(
|
||||
rootStore.restApiContext,
|
||||
|
|
@ -195,12 +197,6 @@ provide(AGENT_ATTACHMENT_URL_KEY, (attachmentId) =>
|
|||
),
|
||||
);
|
||||
|
||||
const queueEdit = ref<{
|
||||
item: AgentChatQueueItem;
|
||||
text: string;
|
||||
unavailable: boolean;
|
||||
saving: boolean;
|
||||
}>();
|
||||
// The stream adds a sent message only once its run starts. On the entry page the first
|
||||
// message shows right away instead, so the page doesn't sit in its empty state meanwhile.
|
||||
const firstMessagePreview = ref<ChatMessage>();
|
||||
|
|
@ -209,45 +205,114 @@ const previewQueueId = ref<string>();
|
|||
const isPreviewingFirstMessage = computed(
|
||||
() => !!firstMessagePreview.value && messages.value.length === 0,
|
||||
);
|
||||
const queueRows = computed(() => {
|
||||
const edit = queueEdit.value;
|
||||
const rows =
|
||||
edit && !queuedMessages.value.some((item) => item.id === edit.item.id)
|
||||
? [...queuedMessages.value, edit.item]
|
||||
: queuedMessages.value;
|
||||
// The previewed message is already on screen as a bubble, so hide only its queue row.
|
||||
if (!isPreviewingFirstMessage.value) return rows;
|
||||
return rows.filter((item) => item.id !== previewQueueId.value);
|
||||
});
|
||||
const queueElement = useTemplateRef<HTMLDivElement>('messageQueue');
|
||||
const queueListId = useId();
|
||||
const queueRows = computed(() =>
|
||||
isPreviewingFirstMessage.value
|
||||
? queuedMessages.value.filter((item) => item.id !== previewQueueId.value)
|
||||
: queuedMessages.value,
|
||||
);
|
||||
const queueExpanded = ref(false);
|
||||
const queueOrder = shallowRef<AgentChatQueueItem[]>();
|
||||
const displayedQueueRows = computed(() => queueOrder.value ?? queueRows.value);
|
||||
const visibleQueueRows = computed(() =>
|
||||
queueExpanded.value ? displayedQueueRows.value : displayedQueueRows.value.slice(0, 2),
|
||||
const queueDisplayItems = computed<ChatMessageQueueItem[]>(() =>
|
||||
displayedQueueRows.value.map((item) => ({
|
||||
id: item.id,
|
||||
message: item.message,
|
||||
attachmentNames: item.attachments?.map((attachment) => attachment.fileName),
|
||||
notice: item.steeringExecutionId ? locale.baseText('agents.chat.queue.steering') : undefined,
|
||||
})),
|
||||
);
|
||||
const canSaveQueueEdit = computed(() => {
|
||||
const edit = queueEdit.value;
|
||||
return (
|
||||
edit &&
|
||||
!edit.saving &&
|
||||
!edit.unavailable &&
|
||||
(edit.text.trim().length > 0 || !!edit.item.attachments?.length)
|
||||
);
|
||||
});
|
||||
watch(queuedMessages, (items) => {
|
||||
const edit = queueEdit.value;
|
||||
if (!edit) return;
|
||||
const current = items.find((item) => item.id === edit.item.id);
|
||||
edit.unavailable = !current || !!current.steeringExecutionId;
|
||||
});
|
||||
function startQueueEdit(item: AgentChatQueueItem) {
|
||||
if (item.steeringExecutionId) return;
|
||||
queueEdit.value = { item, text: item.message, unavailable: false, saving: false };
|
||||
const queueSteerAction = computed<ChatMessageQueueSteerAction>(() => ({
|
||||
label: locale.baseText('agents.chat.queue.steer'),
|
||||
tooltip: locale.baseText('agents.chat.queue.steerTooltip'),
|
||||
icon: 'corner-down-right',
|
||||
}));
|
||||
|
||||
function isDisplayedQueueItemBusy(item: ChatMessageQueueItem) {
|
||||
const queuedItem = displayedQueueRows.value.find((entry) => entry.id === item.id);
|
||||
return !queuedItem || isQueueItemBusy(queuedItem);
|
||||
}
|
||||
|
||||
function editQueuedMessage(id: string) {
|
||||
const item = queuedMessages.value.find((entry) => entry.id === id);
|
||||
if (item) void startQueueEdit(item);
|
||||
}
|
||||
|
||||
async function startQueueEdit(item: AgentChatQueueItem) {
|
||||
if (hasDraft.value || isQueueItemBusy(item) || isSubmissionBlocked.value) return;
|
||||
queueExpanded.value = true;
|
||||
const target = {
|
||||
projectId: props.projectId,
|
||||
agentId: props.agentId,
|
||||
continueSessionId: props.continueSessionId,
|
||||
};
|
||||
function isCurrentTarget() {
|
||||
return (
|
||||
!disposed &&
|
||||
props.projectId === target.projectId &&
|
||||
props.agentId === target.agentId &&
|
||||
props.continueSessionId === target.continueSessionId
|
||||
);
|
||||
}
|
||||
editingQueueId.value = item.id;
|
||||
try {
|
||||
/** Load attachments before removal so a failed download leaves the message queued. */
|
||||
const files = await Promise.all(
|
||||
(item.attachments ?? []).map(async (attachment) => {
|
||||
const url = getChatAttachmentUrl(
|
||||
rootStore.restApiContext,
|
||||
target.projectId,
|
||||
target.agentId,
|
||||
attachment.id,
|
||||
props.channel,
|
||||
);
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`Attachment download failed: ${response.status}`);
|
||||
return new File([await response.blob()], attachment.fileName, {
|
||||
type: attachment.mimeType,
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (!isCurrentTarget() || hasDraft.value) return;
|
||||
const result = await removeQueuedMessage(item.id);
|
||||
if (result !== 'removed' || !isCurrentTarget()) return;
|
||||
inputText.value = item.message;
|
||||
attachedFiles.value = files;
|
||||
editingQueueId.value = undefined;
|
||||
await nextTick();
|
||||
if (isCurrentTarget()) focusInput();
|
||||
} catch (error) {
|
||||
if (isCurrentTarget()) toast.showError(error, locale.baseText('agents.chat.queue.removeError'));
|
||||
} finally {
|
||||
if (isCurrentTarget()) editingQueueId.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Allows Option/Alt + Up to edit the last sent queued message */
|
||||
function onChatInputKeydown(event: KeyboardEvent) {
|
||||
if (
|
||||
!(event.target instanceof HTMLTextAreaElement) ||
|
||||
event.key !== 'ArrowUp' ||
|
||||
!event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
event.isComposing ||
|
||||
event.repeat ||
|
||||
hasDraft.value ||
|
||||
isSubmissionBlocked.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const item = queuedMessages.value.at(-1);
|
||||
if (!item || isQueueItemBusy(item)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void startQueueEdit(item);
|
||||
}
|
||||
|
||||
function isQueueItemBusy(item: AgentChatQueueItem) {
|
||||
return (
|
||||
!!editingQueueId.value ||
|
||||
isReorderingQueue.value ||
|
||||
!!item.steeringExecutionId ||
|
||||
steeringQueueIds.value.has(item.id) ||
|
||||
|
|
@ -255,7 +320,13 @@ function isQueueItemBusy(item: AgentChatQueueItem) {
|
|||
);
|
||||
}
|
||||
function canMoveQueueItem(items: AgentChatQueueItem[], from: number, to: number) {
|
||||
if (!capabilities.value.reorder || queueEdit.value || from === to || !items[from] || !items[to])
|
||||
if (
|
||||
!capabilities.value.reorder ||
|
||||
editingQueueId.value ||
|
||||
from === to ||
|
||||
!items[from] ||
|
||||
!items[to]
|
||||
)
|
||||
return false;
|
||||
return !items.slice(Math.min(from, to), Math.max(from, to) + 1).some(isQueueItemBusy);
|
||||
}
|
||||
|
|
@ -293,43 +364,7 @@ async function moveQueueItem(items: AgentChatQueueItem[], from: number, to: numb
|
|||
);
|
||||
if (queueOrder.value !== reordered) return;
|
||||
queueOrder.value = undefined;
|
||||
await nextTick();
|
||||
queueElement.value
|
||||
?.querySelector<HTMLButtonElement>(
|
||||
`[data-queue-id="${item.id}"] [data-testid="agent-queue-drag-handle"]:not(:disabled)`,
|
||||
)
|
||||
?.focus();
|
||||
}
|
||||
function onQueueHandleKeydown(event: KeyboardEvent, index: number) {
|
||||
if (queueOrder.value || !['ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const delta = event.key === 'ArrowUp' ? -1 : 1;
|
||||
void moveQueueItem(queueRows.value, index, index + delta);
|
||||
}
|
||||
async function saveQueueEdit() {
|
||||
const edit = queueEdit.value;
|
||||
if (!edit || !canSaveQueueEdit.value) return;
|
||||
edit.saving = true;
|
||||
const result = await updateQueuedMessage(edit.item.id, edit.text);
|
||||
if (queueEdit.value !== edit) return;
|
||||
edit.saving = false;
|
||||
if (result === 'updated') queueEdit.value = undefined;
|
||||
else if (result === 'unavailable') edit.unavailable = true;
|
||||
}
|
||||
function onQueueEditKeydown(event: KeyboardEvent) {
|
||||
if (event.isComposing) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!queueEdit.value?.saving) queueEdit.value = undefined;
|
||||
} else if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void saveQueueEdit();
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundJobsActive = computed(
|
||||
() => capabilities.value.backgroundTasks && props.backgroundJobsActive,
|
||||
);
|
||||
|
|
@ -766,7 +801,11 @@ const canIncreaseBudget = computed(() => props.increaseBudget !== undefined);
|
|||
const budgetIncreasePending = ref(false);
|
||||
const isSubmissionBlocked = computed(
|
||||
() =>
|
||||
isPreparingToSend.value || isSubmitting.value || isLoadingHistory.value || hasBudgetStop.value,
|
||||
!!editingQueueId.value ||
|
||||
isPreparingToSend.value ||
|
||||
isSubmitting.value ||
|
||||
isLoadingHistory.value ||
|
||||
hasBudgetStop.value,
|
||||
);
|
||||
// Tools still pending/running after the stream ended (desync): the backend
|
||||
// finished but their terminal events never arrived. Surfacing Stop here lets
|
||||
|
|
@ -795,6 +834,14 @@ const chatPlaceholder = computed(() => {
|
|||
return locale.baseText('agents.chat.answerQuestionPlaceholder');
|
||||
}
|
||||
|
||||
if (queuedMessages.value.length > 0) {
|
||||
return locale.baseText(
|
||||
isMacOs
|
||||
? 'agents.chat.input.placeholder.withQueue.mac'
|
||||
: 'agents.chat.input.placeholder.withQueue.other',
|
||||
);
|
||||
}
|
||||
|
||||
const agentName = props.agentConfig?.name?.trim();
|
||||
return agentName
|
||||
? locale.baseText('agents.chat.input.placeholder.withAgent', {
|
||||
|
|
@ -868,7 +915,7 @@ watch(
|
|||
() => {
|
||||
queuedExternalMessage = undefined;
|
||||
handoffPreview = undefined;
|
||||
queueEdit.value = undefined;
|
||||
editingQueueId.value = undefined;
|
||||
queueExpanded.value = false;
|
||||
queueOrder.value = undefined;
|
||||
firstMessagePreview.value = undefined;
|
||||
|
|
@ -969,6 +1016,7 @@ async function onSubmit(): Promise<SubmitResult> {
|
|||
if (inputText.value.trim() === text) inputText.value = '';
|
||||
attachedFiles.value = attachedFiles.value.filter((file) => !files.includes(file));
|
||||
externalAttachedFiles = [];
|
||||
queueExpanded.value = false;
|
||||
consumeQueuedExternalMessage(text);
|
||||
trackSentToN8nChat(hadNoMessagesBeforeSend);
|
||||
});
|
||||
|
|
@ -1310,223 +1358,40 @@ onBeforeUnmount(() => {
|
|||
:show-attach="showAttach"
|
||||
:accepted-mime-types="acceptedMimeTypes"
|
||||
:can-submit="!isSubmissionBlocked && hasDraft"
|
||||
:disabled="isPreparingToSend"
|
||||
:disabled="isPreparingToSend || !!editingQueueId"
|
||||
data-testid="chat-input"
|
||||
@submit="onSubmit"
|
||||
@stop="stopGenerating"
|
||||
@files-selected="handleFilesSelected"
|
||||
@keydown="onChatInputKeydown"
|
||||
>
|
||||
<template v-if="currentPlan || displayedQueueRows.length" #header>
|
||||
<template v-if="currentPlan" #header>
|
||||
<AgentChatPlan
|
||||
v-if="currentPlan"
|
||||
:key="`${agentId}:${continueSessionId ?? ''}:${currentPlan.planId}`"
|
||||
:plan="currentPlan"
|
||||
:trace-route="continueSessionId ? backgroundTraceRoute : undefined"
|
||||
/>
|
||||
<div
|
||||
</template>
|
||||
<template #above>
|
||||
<ChatMessageQueue
|
||||
v-if="displayedQueueRows.length"
|
||||
ref="messageQueue"
|
||||
:class="$style.messageQueue"
|
||||
data-testid="agent-message-queue"
|
||||
>
|
||||
<Draggable
|
||||
:id="queueListId"
|
||||
:model-value="visibleQueueRows"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
:class="[$style.backgroundJobList, $style.queueList]"
|
||||
:handle="`.${$style.queueDragHandle}:not(:disabled)`"
|
||||
:disabled="!!queueEdit || isReorderingQueue"
|
||||
:move="canDropQueueItem"
|
||||
:ghost-class="$style.queueGhost"
|
||||
:drag-class="$style.queueDragging"
|
||||
@start="startQueueDrag"
|
||||
@end="endQueueDrag"
|
||||
>
|
||||
<template #item="{ element: item, index }">
|
||||
<li :data-queue-id="item.id" data-testid="agent-queued-message">
|
||||
<N8nTooltip
|
||||
:content="locale.baseText('agents.chat.queue.reorderTooltip')"
|
||||
:disabled="!canDragQueueItem(index)"
|
||||
placement="top"
|
||||
>
|
||||
<N8nButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
:class="$style.queueDragHandle"
|
||||
:disabled="!canDragQueueItem(index)"
|
||||
:aria-label="
|
||||
locale.baseText('agents.chat.queue.reorder', {
|
||||
interpolate: { position: index + 1, count: displayedQueueRows.length },
|
||||
})
|
||||
"
|
||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||
data-testid="agent-queue-drag-handle"
|
||||
@keydown="onQueueHandleKeydown($event, index)"
|
||||
>
|
||||
<template #icon>
|
||||
<N8nIcon icon="grip-vertical" size="large" aria-hidden="true" />
|
||||
</template>
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
<div :class="$style.queuePreview" :title="item.message">
|
||||
<N8nInput
|
||||
v-if="queueEdit && queueEdit.item.id === item.id"
|
||||
v-model="queueEdit.text"
|
||||
type="textarea"
|
||||
size="small"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
:readonly="queueEdit.unavailable || queueEdit.saving"
|
||||
:aria-label="locale.baseText('agents.chat.queue.edit')"
|
||||
autofocus
|
||||
@keydown="onQueueEditKeydown"
|
||||
/>
|
||||
<span v-else-if="item.message">{{ item.message }}</span>
|
||||
<p
|
||||
v-if="queueEdit && queueEdit.item.id === item.id && queueEdit.unavailable"
|
||||
:class="$style.queueEditNotice"
|
||||
role="status"
|
||||
>
|
||||
{{
|
||||
locale.baseText(
|
||||
item.steeringExecutionId && queuedMessages.includes(item)
|
||||
? 'agents.chat.queue.editSteeringUnavailable'
|
||||
: 'agents.chat.queue.editUnavailable',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<span
|
||||
v-else-if="item.steeringExecutionId"
|
||||
:class="$style.queueEditNotice"
|
||||
role="status"
|
||||
>
|
||||
{{ locale.baseText('agents.chat.queue.steering') }}
|
||||
</span>
|
||||
<span v-for="attachment in item.attachments" :key="attachment.id">{{
|
||||
attachment.fileName
|
||||
}}</span>
|
||||
</div>
|
||||
<div :class="$style.queueActions">
|
||||
<template v-if="queueEdit && queueEdit.item.id === item.id">
|
||||
<N8nTooltip
|
||||
:content="locale.baseText('agents.chat.queue.save')"
|
||||
:disabled="!canSaveQueueEdit"
|
||||
placement="top"
|
||||
>
|
||||
<N8nButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
:disabled="!canSaveQueueEdit"
|
||||
:aria-label="locale.baseText('agents.chat.queue.save')"
|
||||
@click="saveQueueEdit"
|
||||
>
|
||||
<template #icon
|
||||
><N8nIcon icon="check" size="large" aria-hidden="true"
|
||||
/></template>
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip
|
||||
:content="locale.baseText('agents.chat.queue.cancelEdit')"
|
||||
:disabled="queueEdit.saving"
|
||||
placement="top"
|
||||
>
|
||||
<N8nButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
:disabled="queueEdit.saving"
|
||||
:aria-label="locale.baseText('agents.chat.queue.cancelEdit')"
|
||||
@click="queueEdit = undefined"
|
||||
>
|
||||
<template #icon
|
||||
><N8nIcon icon="x" size="large" aria-hidden="true"
|
||||
/></template>
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<N8nTooltip
|
||||
:content="locale.baseText('agents.chat.queue.steerTooltip')"
|
||||
:disabled="!canSteer || !!queueEdit || isQueueItemBusy(item)"
|
||||
placement="top"
|
||||
>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
:disabled="!canSteer || !!queueEdit || isQueueItemBusy(item)"
|
||||
:aria-label="locale.baseText('agents.chat.queue.steer')"
|
||||
@click="steerQueuedMessage(item.id)"
|
||||
>
|
||||
<template #icon
|
||||
><N8nIcon icon="corner-down-right" size="large" aria-hidden="true"
|
||||
/></template>
|
||||
{{ locale.baseText('agents.chat.queue.steer') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip
|
||||
:content="locale.baseText('agents.chat.queue.edit')"
|
||||
:disabled="!!queueEdit || isQueueItemBusy(item)"
|
||||
placement="top"
|
||||
>
|
||||
<N8nButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
:disabled="!!queueEdit || isQueueItemBusy(item)"
|
||||
:aria-label="locale.baseText('agents.chat.queue.edit')"
|
||||
@click="startQueueEdit(item)"
|
||||
>
|
||||
<template #icon
|
||||
><N8nIcon icon="pencil" size="large" aria-hidden="true"
|
||||
/></template>
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip
|
||||
:content="locale.baseText('agents.chat.queue.remove')"
|
||||
:disabled="isQueueItemBusy(item)"
|
||||
placement="top"
|
||||
>
|
||||
<N8nButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
:disabled="isQueueItemBusy(item)"
|
||||
:aria-label="locale.baseText('agents.chat.queue.remove')"
|
||||
@click="removeQueuedMessage(item.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<N8nIcon icon="trash-2" size="large" aria-hidden="true" />
|
||||
</template>
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
<N8nAiActivityStepButton
|
||||
v-if="displayedQueueRows.length > 2"
|
||||
:aria-expanded="queueExpanded"
|
||||
:aria-controls="queueListId"
|
||||
:disabled="!!queueOrder"
|
||||
full-width
|
||||
@click="queueExpanded = !queueExpanded"
|
||||
>
|
||||
{{
|
||||
queuedMessages.length > 2
|
||||
? locale.baseText('agents.chat.queue.title', {
|
||||
adjustToNumber: queuedMessages.length - 2,
|
||||
interpolate: { count: queuedMessages.length - 2 },
|
||||
})
|
||||
: locale.baseText('agents.chat.queue.edit')
|
||||
}}
|
||||
<template #suffix>
|
||||
<N8nAiActivityStepChevron :open="queueExpanded" direction="down" />
|
||||
</template>
|
||||
</N8nAiActivityStepButton>
|
||||
</div>
|
||||
:displayed-items="queueDisplayItems"
|
||||
:expanded="queueExpanded"
|
||||
:is-reordering="isReorderingQueue"
|
||||
:can-edit="!hasDraft && !isSubmissionBlocked"
|
||||
:steer-action="queueSteerAction"
|
||||
:can-steer="canSteer"
|
||||
:can-drag-queue-item="canDragQueueItem"
|
||||
:is-queue-item-busy="isDisplayedQueueItemBusy"
|
||||
:can-drop-queue-item="canDropQueueItem"
|
||||
@update:expanded="queueExpanded = $event"
|
||||
@drag-start="startQueueDrag"
|
||||
@drag-end="endQueueDrag"
|
||||
@steer="steerQueuedMessage"
|
||||
@edit="editQueuedMessage"
|
||||
@remove="removeQueuedMessage"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="attachedFiles.length > 0" #attachments>
|
||||
<div :class="$style.attachmentsStrip">
|
||||
|
|
@ -1590,38 +1455,19 @@ onBeforeUnmount(() => {
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.backgroundJobs,
|
||||
.messageQueue {
|
||||
.backgroundJobs {
|
||||
min-width: 0;
|
||||
|
||||
--ai-activity-step--height: auto;
|
||||
--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);
|
||||
}
|
||||
|
||||
.backgroundJobs {
|
||||
display: none;
|
||||
background: var(--background--surface);
|
||||
box-shadow: var(--shadow--outline), var(--shadow--xs);
|
||||
border-radius: var(--radius--xs);
|
||||
}
|
||||
|
||||
.messageQueue {
|
||||
--text-color: light-dark(var(--color--neutral-600), var(--text-color--subtler));
|
||||
--icon-color: var(--color--neutral-400);
|
||||
|
||||
margin: calc(-1 * var(--spacing--2xs)) calc(-1 * var(--spacing--2xs)) 0;
|
||||
background: var(--background--subtle);
|
||||
border-radius: var(--radius--lg) var(--radius--lg) 0 0;
|
||||
border-bottom: var(--border);
|
||||
border-bottom-color: var(--border-color--subtle);
|
||||
}
|
||||
|
||||
.messageQueue :global(.n8n-icon) {
|
||||
color: var(--icon-color);
|
||||
}
|
||||
|
||||
.backgroundJobDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1668,84 +1514,6 @@ onBeforeUnmount(() => {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageQueue :global(button[aria-expanded]),
|
||||
.queueList > li {
|
||||
font-size: var(--font-size--2xs);
|
||||
}
|
||||
|
||||
.queueList {
|
||||
max-height: calc(20vh + 2 * var(--height--xl));
|
||||
}
|
||||
|
||||
.queueList > li {
|
||||
align-items: center;
|
||||
padding-inline: var(--spacing--sm);
|
||||
color: var(--text-color);
|
||||
border-bottom: var(--border);
|
||||
border-bottom-color: var(--border-color--subtle);
|
||||
line-height: var(--line-height--md);
|
||||
}
|
||||
|
||||
.messageQueue > .queueList {
|
||||
padding-block: var(--spacing--2xs);
|
||||
|
||||
&:last-child > li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.messageQueue > .queueList:not(:last-child) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.queueActions {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queueDragHandle {
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.queueGhost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.queueDragging {
|
||||
background: var(--background--subtle);
|
||||
box-shadow: var(--shadow--sm);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.queueEditNotice {
|
||||
margin: var(--spacing--3xs) 0;
|
||||
font-size: var(--font-size--2xs);
|
||||
}
|
||||
|
||||
.queuePreview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.jobStatus {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
|
|
|
|||
|
|
@ -476,9 +476,9 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
}
|
||||
}
|
||||
|
||||
async function removeQueuedMessage(queueId: string): Promise<void> {
|
||||
async function removeQueuedMessage(queueId: string): Promise<'removed' | 'failed'> {
|
||||
const threadId = params.continueSessionId?.value ?? acceptedSessionId.value;
|
||||
if (!threadId || removingQueueIds.value.has(queueId)) return;
|
||||
if (!threadId || removingQueueIds.value.has(queueId)) return 'failed';
|
||||
const target = targetKey();
|
||||
removingQueueIds.value.add(queueId);
|
||||
try {
|
||||
|
|
@ -490,15 +490,17 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
queueId,
|
||||
channel.value,
|
||||
);
|
||||
if (disposed || target !== targetKey()) return;
|
||||
if (disposed || target !== targetKey()) return 'failed';
|
||||
queueVersion++;
|
||||
queuedMessages.value = queuedMessages.value.filter((item) => item.id !== queueId);
|
||||
for (const [controller, session] of streams) {
|
||||
if (session.queueId === queueId && !session.executionId) controller.abort();
|
||||
}
|
||||
return 'removed';
|
||||
} catch (error) {
|
||||
if (!disposed && target === targetKey())
|
||||
showError(error, locale.baseText('agents.chat.queue.removeError'));
|
||||
return 'failed';
|
||||
} finally {
|
||||
if (target === targetKey()) {
|
||||
removingQueueIds.value.delete(queueId);
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ defineExpose({
|
|||
@paste="fileDrop.handlePaste"
|
||||
@keydown.capture="handleKeydown"
|
||||
>
|
||||
<slot name="above" />
|
||||
<div
|
||||
v-if="fileDrop.isDragging.value"
|
||||
:class="$style.dropOverlay"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
import type { ChatMessageQueueSteerAction, ChatMessageQueueItem } from './chatMessageQueue.types';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ChatMessageQueue from './ChatMessageQueue.vue';
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (
|
||||
key: string,
|
||||
options?: { interpolate?: Record<string, string | number>; adjustToNumber?: number },
|
||||
) => {
|
||||
if (key === 'chat.messageQueue.title') {
|
||||
return `${options?.interpolate?.count} ${options?.adjustToNumber === 1 ? 'message' : 'messages'} up next`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const items: ChatMessageQueueItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
message: 'First message',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
message: '',
|
||||
attachmentNames: ['notes.txt'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
message: 'Third message',
|
||||
},
|
||||
];
|
||||
|
||||
function mountQueue(
|
||||
overrides: Partial<{
|
||||
displayedItems: ChatMessageQueueItem[];
|
||||
|
||||
expanded: boolean;
|
||||
isReordering: boolean;
|
||||
canEdit: boolean;
|
||||
steerAction: ChatMessageQueueSteerAction;
|
||||
canSteer: boolean;
|
||||
canDragQueueItem: (index: number) => boolean;
|
||||
canDropQueueItem: (event: {
|
||||
draggedContext: { index: number; futureIndex: number };
|
||||
}) => boolean;
|
||||
isQueueItemBusy: (item: ChatMessageQueueItem) => boolean;
|
||||
}> = {},
|
||||
) {
|
||||
return mount(ChatMessageQueue, {
|
||||
props: {
|
||||
displayedItems: items,
|
||||
|
||||
expanded: false,
|
||||
isReordering: false,
|
||||
canEdit: true,
|
||||
steerAction: {
|
||||
label: 'Send now',
|
||||
tooltip: 'Send to the active turn',
|
||||
icon: 'corner-down-right',
|
||||
},
|
||||
canSteer: true,
|
||||
canDragQueueItem: () => true,
|
||||
isQueueItemBusy: () => false,
|
||||
canDropQueueItem: () => true,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChatMessageQueue', () => {
|
||||
it('renders the supplied items and attachment names', () => {
|
||||
const wrapper = mountQueue({ displayedItems: items.slice(0, 2) });
|
||||
|
||||
expect(wrapper.findAll('[data-testid="chat-queued-message"]')).toHaveLength(2);
|
||||
expect(wrapper.get('[data-queue-id="1"] [title]').text()).toBe('First message');
|
||||
expect(wrapper.find('[aria-label="notes.txt"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('emits an expansion update from the queue toggle', async () => {
|
||||
const wrapper = mountQueue();
|
||||
const toggle = wrapper.get('button[aria-expanded]');
|
||||
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false');
|
||||
expect(toggle.text()).toContain('3 messages up next');
|
||||
await toggle.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('update:expanded')).toEqual([[true]]);
|
||||
});
|
||||
|
||||
it('uses a static queue icon when only one item is displayed', () => {
|
||||
const wrapper = mountQueue({
|
||||
displayedItems: [items[0]],
|
||||
});
|
||||
|
||||
expect(wrapper.find('button[aria-expanded]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="chat-queue-drag-handle"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards drag lifecycle events', async () => {
|
||||
const wrapper = mountQueue();
|
||||
const draggable = wrapper.getComponent(Draggable);
|
||||
const event = { oldIndex: 0, newIndex: 2 };
|
||||
|
||||
draggable.vm.$emit('start');
|
||||
draggable.vm.$emit('end', event);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('drag-start')).toEqual([[]]);
|
||||
expect(wrapper.emitted('drag-end')).toEqual([[event]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ArrowUp', 0],
|
||||
['ArrowDown', 2],
|
||||
])('moves an item with %s', async (key, newIndex) => {
|
||||
const wrapper = mountQueue({ expanded: true });
|
||||
const handle = wrapper.get('[data-queue-id="2"] [data-testid="chat-queue-drag-handle"]');
|
||||
|
||||
expect(handle.attributes('aria-keyshortcuts')).toBe('ArrowUp ArrowDown');
|
||||
await handle.trigger('keydown', { key });
|
||||
|
||||
expect(wrapper.emitted('drag-start')).toEqual([[]]);
|
||||
expect(wrapper.emitted('drag-end')).toEqual([[{ oldIndex: 1, newIndex }]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ key: 'ArrowUp' },
|
||||
{ key: 'ArrowDown', isReordering: true },
|
||||
{ key: 'ArrowDown', canDropQueueItem: () => false },
|
||||
{ key: 'Enter' },
|
||||
])('does not move an item when the move is unavailable: %j', async ({ key, ...overrides }) => {
|
||||
const wrapper = mountQueue({ expanded: true, ...overrides });
|
||||
await wrapper
|
||||
.get('[data-queue-id="1"] [data-testid="chat-queue-drag-handle"]')
|
||||
.trigger('keydown', { key });
|
||||
|
||||
expect(wrapper.emitted('drag-start')).toBeUndefined();
|
||||
expect(wrapper.emitted('drag-end')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits the selected queue actions', async () => {
|
||||
const wrapper = mountQueue({ displayedItems: [items[0]] });
|
||||
|
||||
await wrapper.get('[aria-label="Send now"]').trigger('click');
|
||||
await wrapper.get('[aria-label="generic.edit"]').trigger('click');
|
||||
await wrapper.get('[aria-label="generic.delete"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('steer')).toEqual([['1']]);
|
||||
expect(wrapper.emitted('edit')).toEqual([['1']]);
|
||||
expect(wrapper.emitted('remove')).toEqual([['1']]);
|
||||
});
|
||||
|
||||
it('disables actions and dragging for a busy item', () => {
|
||||
const busyItem = { ...items[0], notice: 'Waiting for the next step' };
|
||||
const wrapper = mountQueue({
|
||||
displayedItems: [busyItem, items[1]],
|
||||
|
||||
canDragQueueItem: (index) => index !== 0,
|
||||
isQueueItemBusy: (item) => item.id === busyItem.id,
|
||||
});
|
||||
const row = wrapper.get('[data-queue-id="1"]');
|
||||
|
||||
expect(row.get('[role="group"]').attributes('aria-label')).toBe('Waiting for the next step');
|
||||
expect(row.get('[data-testid="chat-queue-drag-handle"]').attributes('disabled')).toBeDefined();
|
||||
expect(row.find('[aria-label="Send now"]').exists()).toBe(false);
|
||||
for (const label of ['generic.edit', 'generic.delete']) {
|
||||
expect(row.get(`[aria-label="${label}"]`).attributes('disabled')).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders without a steer action', () => {
|
||||
const wrapper = mountQueue({ displayedItems: [items[0]], steerAction: undefined });
|
||||
|
||||
expect(wrapper.find('[aria-label="Send now"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[aria-label="generic.edit"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[aria-label="generic.delete"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('disables the steer action when it is not available', () => {
|
||||
const wrapper = mountQueue({ displayedItems: [items[0]], canSteer: false });
|
||||
|
||||
expect(wrapper.get('[aria-label="Send now"]').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('disables editing independently from other available actions', () => {
|
||||
const wrapper = mountQueue({ displayedItems: [items[0]], canEdit: false });
|
||||
|
||||
expect(wrapper.get('[aria-label="generic.edit"]').attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.get('[aria-label="generic.delete"]').attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,460 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, useId, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nHoverCard,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nScrollArea,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { ChatMessageQueueSteerAction, ChatMessageQueueItem } from './chatMessageQueue.types';
|
||||
|
||||
const props = defineProps<{
|
||||
displayedItems: ChatMessageQueueItem[];
|
||||
expanded: boolean;
|
||||
isReordering: boolean;
|
||||
canEdit: boolean;
|
||||
steerAction?: ChatMessageQueueSteerAction;
|
||||
canSteer?: boolean;
|
||||
canDragQueueItem: (index: number) => boolean;
|
||||
isQueueItemBusy: (item: ChatMessageQueueItem) => boolean;
|
||||
canDropQueueItem: (event: { draggedContext: { index: number; futureIndex: number } }) => boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:expanded': [value: boolean];
|
||||
'drag-start': [];
|
||||
'drag-end': [event: { oldIndex?: number; newIndex?: number }];
|
||||
steer: [id: string];
|
||||
edit: [id: string];
|
||||
remove: [id: string];
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const queueListId = useId();
|
||||
const expanded = computed({
|
||||
get: () => props.expanded,
|
||||
set: (value: boolean) => emit('update:expanded', value),
|
||||
});
|
||||
|
||||
let keyboardHandle: HTMLButtonElement | undefined;
|
||||
|
||||
function onQueueHandleKeydown(event: KeyboardEvent, index: number) {
|
||||
if (props.isReordering || !['ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const futureIndex = index + (event.key === 'ArrowUp' ? -1 : 1);
|
||||
if (
|
||||
!props.canDragQueueItem(index) ||
|
||||
!props.displayedItems[futureIndex] ||
|
||||
!props.canDropQueueItem({ draggedContext: { index, futureIndex } })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.currentTarget instanceof HTMLButtonElement) {
|
||||
keyboardHandle = event.currentTarget;
|
||||
}
|
||||
emit('drag-start');
|
||||
emit('drag-end', { oldIndex: index, newIndex: futureIndex });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isReordering,
|
||||
async (isReordering) => {
|
||||
if (isReordering || !keyboardHandle) return;
|
||||
await nextTick();
|
||||
keyboardHandle?.focus();
|
||||
keyboardHandle = undefined;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.messageQueueEntrance">
|
||||
<div :class="$style.messageQueue" data-testid="chat-message-queue">
|
||||
<button
|
||||
v-if="displayedItems.length > 1"
|
||||
type="button"
|
||||
:class="[$style.queueToggle, { [$style.queueToggleExpanded]: expanded }]"
|
||||
:aria-expanded="expanded"
|
||||
:aria-label="
|
||||
locale.baseText('chat.messageQueue.title', {
|
||||
adjustToNumber: displayedItems.length,
|
||||
interpolate: { count: displayedItems.length },
|
||||
})
|
||||
"
|
||||
:aria-controls="queueListId"
|
||||
:disabled="isReordering"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<N8nIcon icon="list-end" size="medium" color="text-light" aria-hidden="true" />
|
||||
<N8nText bold step="xs" color="text-light">
|
||||
{{
|
||||
locale.baseText('chat.messageQueue.title', {
|
||||
adjustToNumber: displayedItems.length,
|
||||
interpolate: { count: displayedItems.length },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nIcon
|
||||
:icon="expanded ? 'chevron-up' : 'chevron-down'"
|
||||
:class="$style.queueToggleChevron"
|
||||
size="small"
|
||||
color="text-light"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
:class="[
|
||||
$style.queueContent,
|
||||
{
|
||||
[$style.queueContentOpen]: expanded && displayedItems.length > 1,
|
||||
[$style.queueContentSingle]: displayedItems.length === 1,
|
||||
},
|
||||
]"
|
||||
:inert="!expanded && displayedItems.length > 1"
|
||||
>
|
||||
<N8nScrollArea
|
||||
as-child
|
||||
type="hover"
|
||||
max-height="calc(var(--height--lg) * 5)"
|
||||
:class="$style.queueScrollArea"
|
||||
>
|
||||
<Draggable
|
||||
:id="queueListId"
|
||||
:model-value="displayedItems"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
:class="[$style.backgroundJobList, $style.queueList]"
|
||||
:handle="`.${$style.queueDragHandle}:not(:disabled)`"
|
||||
:disabled="isReordering"
|
||||
:move="canDropQueueItem"
|
||||
:ghost-class="$style.queueGhost"
|
||||
:drag-class="$style.queueDragging"
|
||||
@start="emit('drag-start')"
|
||||
@end="emit('drag-end', $event)"
|
||||
>
|
||||
<template #item="{ element: item, index }">
|
||||
<li :data-queue-id="item.id" data-testid="chat-queued-message">
|
||||
<N8nTooltip
|
||||
v-if="displayedItems.length > 1"
|
||||
:content="locale.baseText('chat.messageQueue.reorderTooltip')"
|
||||
:disabled="!canDragQueueItem(index)"
|
||||
placement="top"
|
||||
>
|
||||
<N8nIconButton
|
||||
icon="grip-vertical"
|
||||
variant="ghost"
|
||||
icon-size="medium"
|
||||
size="xsmall"
|
||||
:class="$style.queueDragHandle"
|
||||
:disabled="!canDragQueueItem(index)"
|
||||
:aria-label="
|
||||
locale.baseText('chat.messageQueue.reorder', {
|
||||
interpolate: { position: index + 1, count: displayedItems.length },
|
||||
})
|
||||
"
|
||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||
data-testid="chat-queue-drag-handle"
|
||||
@keydown="onQueueHandleKeydown($event, index)"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
<N8nIcon
|
||||
v-else
|
||||
icon="list-end"
|
||||
size="medium"
|
||||
color="text-light"
|
||||
aria-hidden="true"
|
||||
:class="$style.queueIcon"
|
||||
/>
|
||||
<div :class="$style.queuePreview" :title="item.message">
|
||||
<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>
|
||||
<span
|
||||
:class="$style.queueIndicator"
|
||||
:aria-label="item.attachmentNames?.join(', ')"
|
||||
role="img"
|
||||
tabindex="0"
|
||||
>
|
||||
<N8nIcon icon="paperclip" size="small" color="text-light" />
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<ul :class="$style.queueAttachmentList">
|
||||
<li v-for="(fileName, index) in item.attachmentNames" :key="index">
|
||||
<N8nText size="small">{{ fileName }}</N8nText>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</N8nHoverCard>
|
||||
<N8nTooltip
|
||||
:content="item.notice"
|
||||
:disabled="!item.notice"
|
||||
placement="top"
|
||||
as-child
|
||||
>
|
||||
<div
|
||||
:class="[$style.queueActions, { [$style.queueActionsWithNotice]: item.notice }]"
|
||||
:aria-label="item.notice"
|
||||
: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"
|
||||
: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)"
|
||||
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)"
|
||||
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>
|
||||
</div>
|
||||
</N8nTooltip>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
</N8nScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '@n8n/design-system/css/mixins/motion';
|
||||
@use '@n8n/design-system/css/mixins/utils';
|
||||
@use '@n8n/design-system/css/mixins/mixins';
|
||||
|
||||
.messageQueueEntrance {
|
||||
@include motion.fade-in-up;
|
||||
}
|
||||
|
||||
.messageQueue {
|
||||
--n8n--message-queue--radius: var(--radius--xl) var(--radius--xl) 0 0;
|
||||
--n8n--message--queue--y-offset: var(--spacing--sm);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
margin-inline: var(--spacing--xs);
|
||||
transform: translateY(var(--n8n--message--queue--y-offset));
|
||||
padding-bottom: var(--spacing--sm);
|
||||
background: var(--background--subtle);
|
||||
border-radius: var(--n8n--message-queue--radius);
|
||||
border: var(--border);
|
||||
border-bottom: 0;
|
||||
background-clip: padding-box;
|
||||
transition: transform var(--duration--snappy) var(--easing--ease-out);
|
||||
|
||||
&:hover:not(:has(.queueContentOpen)) {
|
||||
transform: translateY(calc(var(--n8n--message--queue--y-offset) - var(--spacing--3xs)));
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--background--subtle),
|
||||
light-dark(var(--color--neutral-black), var(--color--neutral-white)) 2%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.queueScrollArea {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
@include mixins.scroll-mask(bottom);
|
||||
}
|
||||
|
||||
.backgroundJobList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-inline-end: var(--spacing--xs);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.backgroundJobList li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-inline: var(--spacing--xs);
|
||||
gap: var(--spacing--2xs);
|
||||
height: var(--height--lg);
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--text-color--subtle);
|
||||
overflow-wrap: anywhere;
|
||||
line-height: var(--line-height--lg);
|
||||
}
|
||||
|
||||
.queueContent {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows var(--duration--snappy) var(--easing--ease-out);
|
||||
overflow: hidden;
|
||||
@include motion.reduced-motion;
|
||||
}
|
||||
|
||||
.queueIcon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queueContentSingle {
|
||||
display: block;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.queueContentOpen {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.queueToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing--xs);
|
||||
width: 100%;
|
||||
height: var(--height--lg);
|
||||
padding: var(--spacing--xs);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--n8n--message-queue--radius);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(.queueToggleExpanded) > :global(.n8n-text) {
|
||||
--animation--shimmer--duration: var(--duration--slowest);
|
||||
--animation--shimmer--foreground: var(--text-color--subtle);
|
||||
--animation--shimmer--background: var(--text-color--subtler);
|
||||
|
||||
@include motion.shimmer;
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--border);
|
||||
outline-offset: calc(-1 * var(--spacing--5xs));
|
||||
}
|
||||
}
|
||||
.queueToggleChevron {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.queueToggleExpanded {
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
.queueActions {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: calc(var(--spacing--xs) * -1);
|
||||
|
||||
:global(.n8n-icon) {
|
||||
color: var(--icon-color);
|
||||
}
|
||||
}
|
||||
|
||||
.queueDragHandle {
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
margin-inline-start: calc(var(--spacing--3xs) * -1);
|
||||
color: var(--icon-color);
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.queueGhost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.queueDragging {
|
||||
background: var(--background--subtle);
|
||||
box-shadow: var(--shadow--sm);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.queueActionsWithNotice :global(button:disabled) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.queueIndicator {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.queueAttachmentList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--spacing--xs);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.queuePreview {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> :global(.n8n-text) {
|
||||
@include utils.utils-ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import type { IconName } from '@n8n/design-system';
|
||||
|
||||
export interface ChatMessageQueueItem {
|
||||
/** Stable ID. */
|
||||
id: string;
|
||||
/** Message sent by user that is queued. */
|
||||
message: string;
|
||||
/** Lists attachment file names shown in the attachment card. */
|
||||
attachmentNames?: string[];
|
||||
/** Shows a status tooltip on the action group and hides the Steer button. */
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageQueueSteerAction {
|
||||
/** Supplies the translated button text and accessible label. */
|
||||
label: string;
|
||||
/** Explains the Steer action in the button tooltip. */
|
||||
tooltip: string;
|
||||
/** Selects the icon shown before the button text. */
|
||||
icon: IconName;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user