feat(editor): Improve agent file upload visibility (#35513)

This commit is contained in:
Rob Hough
2026-08-07 15:07:30 +01:00
committed by GitHub
parent 7f680bfc57
commit 75a0cdfd20
4 changed files with 77 additions and 48 deletions
@@ -7557,8 +7557,9 @@
"agents.builder.files.uploadTotalTooLarge.message": "These files would make the knowledge base larger than {size} GB. Delete files or upload a smaller selection.",
"agents.builder.files.uploadTooManyFiles.title": "Too many files",
"agents.builder.files.uploadTooManyFiles.message": "You can upload up to {max} files at a time. Select fewer files and try again.",
"agents.builder.files.empty": "No files uploaded yet.",
"agents.builder.files.addFile": "Add file",
"agents.builder.files.empty": "No files uploaded",
"agents.builder.files.emptyDescription": "Upload CSV, PDF, Markdown, or TXT files this agent can search and read",
"agents.builder.files.addFile": "Upload file",
"agents.builder.files.loading": "Loading files...",
"agents.builder.files.origin.user": "User",
"agents.builder.files.uploaded": "File uploaded",
@@ -56,12 +56,13 @@ const file: AgentFileDto = {
};
describe('AgentFilesPanel', () => {
it('enables the upload button with the normal upload tooltip', () => {
const wrapper = mountPanel();
it('enables the upload button with the normal upload tooltip when files exist', () => {
const wrapper = mountPanel({ files: [file] });
const uploadButton = wrapper.find('[data-testid="agent-files-upload"]');
expect(uploadButton.attributes('disabled')).toBeUndefined();
expect(uploadButton.attributes('aria-label')).toBe('agents.builder.files.addFile');
expect(wrapper.findComponent({ name: 'N8nEmptyState' }).exists()).toBe(false);
});
it('shows the normal empty-state message', () => {
@@ -70,7 +71,24 @@ describe('AgentFilesPanel', () => {
expect(wrapper.text()).toContain('agents.builder.files.empty');
});
it('shows the knowledge base title with a tooltip and icon-only add action', () => {
it('opens the file picker from the empty-state button', async () => {
const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click');
const wrapper = mountPanel({ files: [] });
wrapper.findComponent({ name: 'N8nEmptyState' }).vm.$emit('click:button');
await wrapper.vm.$nextTick();
expect(clickSpy).toHaveBeenCalledOnce();
clickSpy.mockRestore();
});
it('disables the empty-state button when uploads are disabled', () => {
const wrapper = mountPanel({ files: [], disabled: true });
expect(wrapper.findComponent({ name: 'N8nEmptyState' }).props('buttonDisabled')).toBe(true);
});
it('shows the knowledge base title with a tooltip and add action', () => {
const wrapper = mountPanel({ files: [file] });
expect(wrapper.find('[data-testid="agent-files-title"]').text()).toContain(
@@ -79,9 +97,7 @@ describe('AgentFilesPanel', () => {
const uploadButton = wrapper.findComponent({ name: 'N8nButton' });
expect(uploadButton.props('variant')).toBe('ghost');
expect(uploadButton.props('iconOnly')).toBe(true);
expect(uploadButton.props('icon')).toBe('plus');
expect(wrapper.find('[data-testid="agent-files-upload"]').text()).toBe('');
});
it('renders uploaded files as table rows with owner, type, size, and date metadata', () => {
@@ -6,6 +6,8 @@ import {
N8nIcon,
N8nTableBase,
N8nTooltip,
N8nText,
N8nEmptyState,
} from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
@@ -41,10 +43,17 @@ const i18n = useI18n();
const fileInput = useTemplateRef<HTMLInputElement>('fileInput');
const isMutating = computed(() => props.uploading || props.deletingFileId !== null);
const isUploadDisabled = computed(() => props.disabled || props.loading || isMutating.value);
const uploadTooltip = computed(() => i18n.baseText('agents.builder.files.addFile' as BaseTextKey));
const uploadButtonLabel = computed(() =>
i18n.baseText('agents.builder.files.addFile' as BaseTextKey),
);
const acceptAttr = ALLOWED_AGENT_FILE_EXTENSIONS.join(',');
const emptyStateHeader = computed(() => i18n.baseText('agents.builder.files.empty' as BaseTextKey));
const emptyStateDescription = computed(() =>
i18n.baseText('agents.builder.files.emptyDescription' as BaseTextKey),
);
function getFileIcon(file: AgentFileDto) {
const extension = file.fileName.split('.').pop()?.toLowerCase();
if (extension === 'csv' || file.mimeType === 'text/csv') return 'file-code';
@@ -128,15 +137,15 @@ function onFilesSelected(event: Event) {
<template>
<div :class="$style.panel" data-testid="agent-files-panel">
<div :class="$style.toolbar">
<span :class="$style.title" data-testid="agent-files-title">
<N8nText bold :class="$style.title" data-testid="agent-files-title">
{{ i18n.baseText('agents.builder.files.title') }}
<N8nTooltip
:content="i18n.baseText('agents.builder.files.titleTooltip' as BaseTextKey)"
placement="top"
>
<N8nIcon icon="circle-help" size="small" :class="$style.titleIcon" />
<div :class="$style.titleIcon"><N8nIcon icon="circle-help" size="small" /></div>
</N8nTooltip>
</span>
</N8nText>
<input
ref="fileInput"
@@ -148,21 +157,31 @@ function onFilesSelected(event: Event) {
@change="onFilesSelected"
/>
<N8nTooltip :content="uploadTooltip" placement="top">
<N8nButton
variant="ghost"
size="small"
icon="plus"
icon-only
:disabled="isUploadDisabled"
:aria-label="uploadTooltip"
data-testid="agent-files-upload"
@click="openFilePicker"
/>
</N8nTooltip>
<N8nButton
v-if="!props.loading && props.files.length > 0"
variant="ghost"
size="small"
icon="plus"
:disabled="isUploadDisabled"
:aria-label="uploadButtonLabel"
data-testid="agent-files-upload"
@click="openFilePicker"
>
{{ uploadButtonLabel }}
</N8nButton>
</div>
<div :class="$style.tableContainer">
<N8nEmptyState
v-if="!props.loading && props.files.length === 0"
:icon="{ type: 'icon', value: 'file' }"
:class="$style.emptyState"
:heading="emptyStateHeader"
:description="emptyStateDescription"
:button-text="uploadButtonLabel"
:button-disabled="isUploadDisabled"
@click:button="openFilePicker"
/>
<div v-else :class="$style.tableContainer">
<N8nTableBase :max-displayed-rows="10">
<tbody>
<tr
@@ -212,14 +231,6 @@ function onFilesSelected(event: Event) {
</td>
</tr>
</template>
<tr v-if="!props.loading && props.files.length === 0" :class="$style.lastRow">
<td :colspan="6">
<span :class="$style.emptyMessage" data-testid="agent-files-empty">
{{ i18n.baseText('agents.builder.files.empty') }}
</span>
</td>
</tr>
</tbody>
</N8nTableBase>
</div>
@@ -230,7 +241,7 @@ function onFilesSelected(event: Event) {
.panel {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
gap: var(--spacing--xs);
width: 100%;
}
@@ -245,15 +256,14 @@ function onFilesSelected(event: Event) {
.title {
display: inline-flex;
align-items: center;
gap: var(--spacing--3xs);
min-width: 0;
color: var(--text-color--subtler);
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
line-height: var(--line-height--sm);
}
.titleIcon {
width: var(--height--xs);
height: var(--height--xs);
display: grid;
place-items: center;
color: var(--text-color--subtler);
}
@@ -7,6 +7,7 @@ import {
N8nIconButton,
N8nTableBase,
N8nTooltip,
N8nText,
} from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
@@ -74,17 +75,18 @@ function onAction(actionId: VectorStoreAction, vectorStore: AgentJsonVectorStore
<template>
<div :class="$style.panel" data-testid="agent-vector-stores-panel">
<div v-if="props.vectorStores.length > 0" :class="$style.toolbar">
<span :class="$style.title" data-testid="agent-vector-stores-title">
<div :class="$style.toolbar">
<N8nText bold :class="$style.title" data-testid="agent-vector-stores-title">
{{ i18n.baseText('agents.builder.vectorStores.panel.title') }}
<N8nTooltip
:content="i18n.baseText('agents.builder.vectorStores.panel.titleTooltip')"
placement="top"
>
<N8nIcon icon="circle-help" size="small" :class="$style.titleIcon" />
<div :class="$style.titleIcon"><N8nIcon icon="circle-help" size="small" /></div>
</N8nTooltip>
</span>
</N8nText>
<N8nTooltip
v-if="props.vectorStores.length > 0"
:content="i18n.baseText('agents.builder.vectorStores.panel.connectButton')"
placement="top"
>
@@ -172,7 +174,7 @@ function onAction(actionId: VectorStoreAction, vectorStore: AgentJsonVectorStore
.panel {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
gap: var(--spacing--xs);
width: 100%;
}
@@ -187,14 +189,14 @@ function onAction(actionId: VectorStoreAction, vectorStore: AgentJsonVectorStore
.title {
display: inline-flex;
align-items: center;
gap: var(--spacing--3xs);
color: var(--text-color--subtler);
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
line-height: var(--line-height--sm);
min-width: 0;
}
.titleIcon {
width: var(--height--xs);
height: var(--height--xs);
display: grid;
place-items: center;
color: var(--text-color--subtler);
}