From 9f423560670218cab3ffc3e8b971cc7a416aa85e Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 21 Aug 2026 14:46:57 +0200 Subject: [PATCH 1/4] wip(upload): add temporary file messages in a single batch - if await for compressImage (~100-200ms), it would produce visual disruption Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Maksim Sukharev --- src/stores/upload.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/stores/upload.ts b/src/stores/upload.ts index 55dcc1ed7c5..1f38ef57a25 100644 --- a/src/stores/upload.ts +++ b/src/stores/upload.ts @@ -492,14 +492,19 @@ export const useUploadStore = defineStore('upload', () => { // Tag previously indexed files and add temporary messages to the MessagesList // If caption is provided, attach to the last temporary message - const lastIndex = getInitialisedUploads(uploadId).at(-1)![0] - for (const [index, uploadedFile] of getInitialisedUploads(uploadId)) { - // Compress before building the temporary message, so that the caption, - // the parent and the final file parameters are applied in a single pass - // and the backend receives the final file names (e.g. .webp instead of .png) - const compressed = compressImages - ? await compressUploadedImage(uploadId, index) - : null + const initialisedUploads = getInitialisedUploads(uploadId) + const lastIndex = initialisedUploads.at(-1)![0] + + // Compress all images in parallel first, then build each temporary message in a + // single pass over the precompressed results, so that the caption, the parent and + // the final file parameters are applied consistently and the backend receives the + // final file names (e.g. .webp instead of .png). + const compressedFiles = compressImages + ? await Promise.all(initialisedUploads.map(([index]) => compressUploadedImage(uploadId, index))) + : [] + + for (const [key, [index, uploadedFile]] of initialisedUploads.entries()) { + const compressed = compressedFiles[key] ?? null dismissCompressImage(uploadedFile.temporaryMessage.referenceId) // Store the previously created temporary message @@ -525,9 +530,9 @@ export const useUploadStore = defineStore('upload', () => { uploads[uploadId].files[index].temporaryMessage = message // Add temporary messages (files) to the messages list vuexStore.dispatch('addTemporaryMessage', { token, message }) - // Scroll the message list - EventBus.emit('scroll-chat-to-bottom', { smooth: true, force: true }) } + // Scroll the message list + EventBus.emit('scroll-chat-to-bottom', { smooth: true, force: true }) // With the conversation-subfolders feature enabled, // stage uploads inside the backend-provided Draft folder and From 9a1b921b290caf4d95835007bb3a6e32bb58a451 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 21 Aug 2026 14:42:30 +0200 Subject: [PATCH 2/4] fix(upload): combine temporary file messages - if messages will be combined after they shared, they should look correctly during upload as well Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Maksim Sukharev --- .../MessagesGroup/Message/MessageItem.vue | 4 ++-- .../Message/MessagePart/MessageBody.vue | 4 ++-- src/utils/combineFileMessages.ts | 13 ++++++++----- src/utils/message.ts | 16 ++++++++++++++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue index a0e7e904c07..8203381523e 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue @@ -113,7 +113,7 @@ import { hasTalkFeature } from '../../../../services/CapabilitiesManager.ts' import { EventBus } from '../../../../services/EventBus.ts' import { useActorStore } from '../../../../stores/actor.ts' import { useChatExtrasStore } from '../../../../stores/chatExtras.ts' -import { isFilePreviewParameter, isTemporaryId } from '../../../../utils/message.ts' +import { isFilePreviewParameter, isTemporaryId, isTemporaryMessage } from '../../../../utils/message.ts' const LocationCard = defineAsyncComponent(() => import('./MessagePart/LocationCard.vue')) @@ -187,7 +187,7 @@ export default { }, isTemporary() { - return !this.isScheduledMessage && isTemporaryId(this.message.id) + return !this.isScheduledMessage && isTemporaryMessage(this.message) }, isDeletedMessage() { diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue index 4a2a42d7bc8..229501be23e 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue @@ -226,7 +226,7 @@ import { getFileKeys, getFilePreviewKeys, isFilePreviewParameter, - isTemporaryId, + isTemporaryMessage, } from '../../../../../utils/message.ts' import { parseMentions, parseSpecialSymbols } from '../../../../../utils/textParse.ts' @@ -438,7 +438,7 @@ export default { }, isTemporary() { - return !this.isScheduledMessage && isTemporaryId(this.message.id) + return !this.isScheduledMessage && isTemporaryMessage(this.message) }, isScheduledSendingFailure() { diff --git a/src/utils/combineFileMessages.ts b/src/utils/combineFileMessages.ts index bbe2ed48c88..1d47331a42d 100644 --- a/src/utils/combineFileMessages.ts +++ b/src/utils/combineFileMessages.ts @@ -6,7 +6,7 @@ import type { ChatMessage } from '../types/index.ts' import { MESSAGE } from '../constants.ts' -import { getFileKeys, hasOnlyFilePlaceholders, isTemporaryId } from './message.ts' +import { getFileKeys, hasOnlyFilePlaceholders } from './message.ts' export type CombinedFileMessage = ChatMessage & { /** Ids of all messages combined into this one, in chronological order */ @@ -25,9 +25,9 @@ function isCombinableFileMessage(message: ChatMessage): boolean { } // TODO threads? - // TODO unified uploader? - // Temporary messages, which are not sent yet (or failed to be sent), are shown separately - if (isTemporaryId(message.id) || (message as ChatMessage & { sendingFailure?: string }).sendingFailure) { + // Messages that failed to send are shown separately, not combined into a group + // (sendingFailure is only ever set on temporary messages). + if ((message as ChatMessage & { sendingFailure?: string }).sendingFailure) { return false } @@ -82,7 +82,10 @@ function canBeCombinedWith(message1: ChatMessage, message2: ChatMessage): boolea * Create a single message out of several file shares. * The combined message is based on the last message of the group, so that the timestamp, * the reading state, the reactions and the message actions refer to an existing message - * (reactions of the other messages of the group are not shown) + * (reactions of the other messages of the group are not shown). + * If any of the combined ids is still temporary, the combined message is treated as + * temporary too (see isTemporaryMessage) and its actions stay disabled until every + * file in the group has been sent. * * @param messages array of grouped file share messages, in chronological order */ diff --git a/src/utils/message.ts b/src/utils/message.ts index 9dff3db5915..664676bad07 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -245,6 +245,22 @@ export function isTemporaryId(id: ChatMessage['id'] | string): boolean { return id.toString().startsWith('temp-') } +/** + * Returns whether the given message is not sent yet. A combined file share's own id is + * taken from whichever message is currently last in the group, so it can already be a + * confirmed (non-temporary) id even while other files of the same group are still in + * flight. combinedMessageIds is checked instead, so any still-temporary file in the + * group is caught. + * + * @param message Chat message + */ +export function isTemporaryMessage(message: ChatMessage): boolean { + const combinedMessageIds = (message as ChatMessage & { combinedMessageIds?: ChatMessage['id'][] }).combinedMessageIds + return combinedMessageIds + ? combinedMessageIds.some(isTemporaryId) + : isTemporaryId(message.id) +} + /** * Returns whether the given system message should be hidden in the UI * From 22112378ac26ebc328d735c38d86428f698da45b Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 21 Aug 2026 14:48:24 +0200 Subject: [PATCH 3/4] fix(upload): redesign upload progress - move circle in file icon/preview area - render indeterminate state as spinner Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Maksim Sukharev --- .../MessagesGroup/Message/MessageItem.vue | 4 ---- .../Message/MessagePart/FilePreview.vue | 21 +++++++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue index 8203381523e..9af87edeacd 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue @@ -490,10 +490,6 @@ export default { background-color: var(--color-primary-element-light-hover); border: 1px solid var(--color-border-dark); } - - :deep(.file-preview__progress) { - inset-inline-start: calc((var(--progress-bar-height) + var(--default-grid-baseline) * 3) * -1) - } } &.incoming { diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue index 99d702cd4c3..13bf527304c 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue @@ -60,8 +60,9 @@ + :value="uploadProgress || 30" /> Date: Wed, 26 Aug 2026 14:17:57 +0200 Subject: [PATCH 4/4] fix(chat): hide upload progress on failed upload Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Maksim Sukharev --- .../MessagesGroup/Message/MessagePart/FilePreview.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue index 13bf527304c..448c91b2c06 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue @@ -523,7 +523,7 @@ export default { showUploadProgress() { return this.isTemporaryUpload && !this.isUploadEditor - && ['shared', 'sharing', 'successUpload', 'uploading', 'failedUpload'].includes(this.uploadFile?.status) + && ['shared', 'sharing', 'successUpload', 'uploading'].includes(this.uploadFile?.status) }, hasTemporaryImageUrl() {