Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,67 @@
animation: loading-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

@keyframes show-after-delay {
0%, 80% {
opacity: 0;
max-height: 0em;
}
93.3% {
opacity: 0;
max-height: 1.5em;
}
100% {
opacity: 1;
}
}
.animate-show-after-7s {
animation: show-after-delay 5.5s forwards;

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSS class name animate-show-after-7s implies a 7s delay, but it currently runs show-after-delay for 5.5s (with the element only becoming visible at the end). This will show the “long upload” hint earlier than intended and is likely out of sync with the 7s requirement; consider implementing an actual 7s delay (e.g., via animation-delay: 7s) and aligning the keyframes accordingly.

Suggested change
animation: show-after-delay 5.5s forwards;
animation: show-after-delay 7s forwards;

Copilot uses AI. Check for mistakes.
}

@keyframes alternate-fade-first {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
40% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}

@keyframes alternate-fade-second {
0% {
opacity: 0;
}
50% {
opacity: 0;
}
60% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
opacity: 0;
}
}

.animate-alternate-first {
animation: alternate-fade-first 10s infinite;
}

.animate-alternate-second {
animation: alternate-fade-second 10s infinite;
}

.white-shadow {
box-shadow: 0 -20px 16px 4px white;
}
Expand Down
42 changes: 35 additions & 7 deletions frontend/src/pages/chat/conversation/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export function ChatInput({ textareaRef, chatId, configuration, isDisabled, isEm
const { updateContext, context } = useExtensionContext(chatId);
const [defaultValues, setDefaultValues] = useState<UserArgumentDefaultValueByExtensionIDAndName>({});
const {
uploadingFiles,
fullFileSlots,
allowedFileNameExtensions,
chatFiles,
Expand Down Expand Up @@ -202,12 +201,41 @@ export function ChatInput({ textareaRef, chatId, configuration, isDisabled, isEm
{isEmpty && <Suggestions configuration={configuration} theme={theme} onSelect={setInput} />}

<div className="flex flex-wrap gap-2">
{chatFiles.map((file) => (
<FileItemComponent key={file.id} file={file} onRemove={remove} />
))}
{uploadingFiles.map((file, n) => (
<FileItemComponent key={`${n}-${file.name}`} file={{ fileName: file.name }} loading={true} />
))}
{(() => {
const uploadingFilesWithMetadata = uploadMutations
.filter((m) => m.status === 'pending')
.map((m, index) => ({
fileName: m.variables?.file?.name || '',
isUploading: true,
originalFile: m.variables?.file,
uploadTime: new Date(),

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uploadTime is set to new Date() during render for each pending mutation, so it changes on every re-render. Because it participates in sorting, the ordering of file chips can become unstable/jittery; use a stable timestamp from the mutation (e.g., m.submittedAt) instead.

Suggested change
uploadTime: new Date(),
uploadTime: new Date(m.submittedAt || 0),

Copilot uses AI. Check for mistakes.
uniqueKey: `${m.submittedAt}-${index}`,
Comment on lines +207 to +212

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uniqueKey includes the index from the filtered pending mutations list. When one pending upload settles, indices of the remaining items can shift, changing keys and remounting FileItemComponent (which will reset the “show after N seconds” animation/timer). Prefer a key derived only from stable mutation identity (e.g., submittedAt plus file name) rather than the array index.

Suggested change
.map((m, index) => ({
fileName: m.variables?.file?.name || '',
isUploading: true,
originalFile: m.variables?.file,
uploadTime: new Date(),
uniqueKey: `${m.submittedAt}-${index}`,
.map((m) => ({
fileName: m.variables?.file?.name || '',
isUploading: true,
originalFile: m.variables?.file,
uploadTime: new Date(),
uniqueKey: `${m.submittedAt}-${m.variables?.file?.name || ''}`,

Copilot uses AI. Check for mistakes.
}));

const filesWithMetadata = [
...uploadingFilesWithMetadata,
...chatFiles.map((file) => ({
fileName: file.fileName,
isUploading: false,
originalFile: file,
uploadTime: new Date(file.uploadedAt),
uniqueKey: `chat-${file.id}`,
})),
].sort((a, b) => {
const alphabeticalOrder = a.fileName.localeCompare(b.fileName);
if (alphabeticalOrder !== 0) return alphabeticalOrder;
return a.uploadTime.getTime() - b.uploadTime.getTime();
});

return filesWithMetadata.map((file) => (
<FileItemComponent
key={file.uniqueKey}
file={file.isUploading ? { fileName: file.fileName } : (file.originalFile as FileDto)}
onRemove={file.isUploading ? undefined : remove}
loading={file.isUploading}
/>
));
})()}
</div>

{extensionFilterChips.map(
Expand Down
57 changes: 45 additions & 12 deletions frontend/src/pages/chat/conversation/FileItem.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { IconFile, IconRotate2, IconTrash } from '@tabler/icons-react';
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileDto } from 'src/api';
import { cn } from 'src/lib';
import { extractType } from 'src/pages/utils';
import { texts } from 'src/texts';

type FileItemProps = {
file: FileDto | { fileName: string };
Expand All @@ -10,26 +13,35 @@ type FileItemProps = {
};

export const FileItemComponent = ({ file, onRemove, loading }: FileItemProps) => {
const [isDeleting, setIsDeleting] = useState(false);
const { i18n } = useTranslation();
const fileName = file.fileName;
const fileType = 'mimeType' in file ? extractType(file) : undefined;

const handleRemove = (e: React.MouseEvent) => {
e.preventDefault();
if (onRemove && 'id' in file) {
if (onRemove && 'id' in file && !isDeleting) {
setIsDeleting(true);
onRemove(file);
}
};

return (
<div
className="group relative flex w-48 items-center gap-2 overflow-clip rounded-md bg-gray-100 p-2 shadow-sm"
className={cn(
'group relative flex w-48 items-center gap-2 overflow-clip rounded-md p-2 shadow-sm transition-all',
isDeleting ? 'bg-gray-50' : 'bg-gray-100',
)}
data-testid="file-chip"
>
<div className="flex w-full items-center gap-2" data-testid={loading ? 'file-chip-uploading' : 'file-chip-uploaded'}>
<div className="relative h-6 w-6 flex-shrink-0">
{loading ? <IconRotate2 className="loading w-6" /> : <IconFile />}
<div
className={cn('flex w-full items-center gap-2', isDeleting && 'opacity-50')}
data-testid={loading ? 'file-chip-uploading' : 'file-chip-uploaded'}
>
<div className="relative h-10 w-10 flex-shrink-0 p-1">
{loading ? <IconRotate2 className="loading h-full w-full" /> : <IconFile className="h-full w-full" />}
{fileType && (
<span className="absolute -right-1 -bottom-1 truncate rounded-md bg-black px-[3px] py-[1px] text-[8px] text-white">
<span className="absolute right-0 bottom-0 truncate rounded-md bg-black px-[3px] py-[1px] text-[8px] text-white">
{fileType}
</span>
)}
Expand All @@ -38,15 +50,36 @@ export const FileItemComponent = ({ file, onRemove, loading }: FileItemProps) =>
<div className="min-w-0 flex-grow">
<div className="flex flex-col">
<span className="truncate text-sm font-medium">{fileName}</span>
{loading && (
<div className="animate-show-after-7s relative h-5">
<span className="animate-alternate-first absolute top-0 right-0 left-0 truncate text-sm font-medium text-orange-800 italic">
{texts.files.waitingMessage1}
</span>
<span className="animate-alternate-second absolute top-0 right-0 left-0 truncate text-sm font-medium text-orange-800 italic">
{texts.files.waitingMessage2}
</span>
Comment on lines +54 to +60

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new long-upload hint text does not match the copy in #982 (“This may take a while...”), and the two-message alternation (‘processing...’ / ‘a few minutes more’) may not satisfy the requirement as written. If the issue expects the exact string, update the translation keys/copy accordingly (and ensure it’s only shown after the intended delay).

Copilot uses AI. Check for mistakes.
</div>
)}
Comment on lines +53 to +62

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new long-upload UI behavior (showing an additional hint after a delay) isn’t covered by existing frontend tests. Since this PR introduces user-visible behavior tied to timing/visibility, consider adding a unit test around the FileItemComponent/ChatInput rendering that asserts the hint elements/classes are present for loading items (and, if feasible, that they are delayed via the expected CSS class).

Copilot uses AI. Check for mistakes.
{!loading && 'uploadedAt' in file && file.uploadedAt && (
<div className="text-xs text-gray-500">
{new Date(file.uploadedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : undefined)}
</div>
)}
</div>
</div>

{!loading && onRemove && (
<div className="absolute top-0 right-0 bottom-0 flex items-center bg-gray-100 p-1 pr-2 opacity-0 transition-all group-hover:opacity-100">
<button className="text-red-500" onClick={handleRemove}>
<IconTrash className="h-4 w-4" />
</button>
</div>
<button
className={cn(
'absolute top-0 right-0 bottom-0 flex items-center bg-gray-100 p-1 pr-2 text-red-500 opacity-0 transition-all group-hover:opacity-100 hover:bg-gray-200',
isDeleting && 'cursor-not-allowed opacity-50',
)}
onClick={handleRemove}
disabled={isDeleting}
aria-label="Delete file"
>
<IconTrash className="h-4 w-4" />
</button>
)}
</div>
</div>
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/pages/chat/useChatDropzone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ export const useChatDropzone = () => {
.filter((m) => m.status === 'pending')
.map((m) => m.variables?.file)
.filter(Boolean)
.map((f) => f!)
.filter((f) => !chatFiles?.some((chatFile) => chatFile?.fileName === f?.name));
.map((f) => f!);

const getFileSlots = () => {
return userBucket?.extensions.map((x) => {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/texts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ function load() {
uploadImageFailedUseThePaperclip: (fileName: string) => translate('files.uploadImageFailedUseThePaperclip', { fileName }),
uploading: translate('files.uploading'),
uploadMultiple: (fileCount: number) => translate('files.uploadMultiple', { fileCount }),
waitingMessage1: translate('files.waitingMessage1'),
waitingMessage2: translate('files.waitingMessage2'),
wholeFileTooLarge: translate('files.wholeFileTooLarge'),
},
login: {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/texts/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ export const de: typeof en = {
'Datei {{fileName}} konnte nicht hochgeladen werden. Bilder können über das 📎-Symbol im Nachrichtenfenster hochgeladen werden.',
uploading: 'Datei wird hochgeladen',
uploadMultiple: '{{fileCount}} Dateien werden hochgeladen...',
waitingMessage1: 'lädt...',
waitingMessage2: 'noch einige Minuten',
wholeFileTooLarge: 'Die Datei ist größer als die definierte maximale Dateigröße',
},
login: {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/texts/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ export const en = {
'Failed to upload file {{fileName}}. Images can be uploaded via the 📎 symbol in the message window.',
uploading: 'Uploading File',
uploadMultiple: 'Uploading {{fileCount}} files...',
waitingMessage1: 'processing...',
waitingMessage2: 'a few minutes more',
wholeFileTooLarge: 'The file is larger than the defined file size limit',
},
login: {
Expand Down