diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js
index d734a306..0cc0d46b 100644
--- a/src/frontend/web/von_interface/static/js/chatTab.js
+++ b/src/frontend/web/von_interface/static/js/chatTab.js
@@ -16,7 +16,7 @@ import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_NARROW_
import { createVoiceConversation } from './voiceConversation.js';
import { getClientContext } from './clientContext.js';
import { createDictationController } from './dictation.js';
-import { isConversationImageFile, initialiseImagePicker, renderImageAttachments, renderImageComposer, uploadConversationImage, imagesBlocked, imageItems, takeImages, restoreImages, descriptorsForIds } from "./utils/conversationImages.js";
+import { isConversationImageFile, initialiseImagePicker, renderImageAttachments, renderImageComposer, uploadConversationImage, attachmentBlockMessage, imagesBlocked, imageItems, takeImages, restoreImages, descriptorsForIds } from "./utils/conversationImages.js";
// Chat Tab Module
import { annotateTurn, fetchWithTimeout, getJsonDetailed, getUserContext, getWindowSessionId, postJson, WINDOW_SESSION_HEADER } from './apiService.js';
import {
@@ -6802,9 +6802,9 @@ function updateSendButtonForCurrentChatState() {
if (queueSubmissionInFlight) {
sendButton.title = 'Saving this prompt to the conversation queue.';
} else if (imagePreparationBlocked && !uploadInFlight) {
- sendButton.title = 'Remove the failed image attachment before sending.';
+ sendButton.title = attachmentBlockMessage(activeChatSessionId);
} else if (uploadInFlight) {
- sendButton.title = ATTACHMENT_UPLOAD_SEND_BLOCK_MESSAGE;
+ sendButton.title = attachmentBlockMessage(activeChatSessionId);
} else {
sendButton.title = sessionBusy
? 'Queue a separate request after the current turn finishes. Use Steer to guide the active turn.'
@@ -21492,9 +21492,6 @@ let uploadUiState = {
};
const PENDING_FILE_COPY_SESSION_FALLBACK_KEY = '__pending_chat_session__';
-const ATTACHMENT_UPLOAD_SEND_BLOCK_MESSAGE = (
- 'Wait for the attachment upload to finish before sending.'
-);
const pendingFileCopyConceptIdsBySession = new Map();
const pendingFileCopyDisplayNamesBySession = new Map();
const uploadStatesBySession = new Map();
@@ -21502,7 +21499,8 @@ const uploadStatesBySession = new Map();
function getInFlightUploadStateForSession(sessionId = activeChatSessionId) {
const sessionKey = getPendingFileCopySessionKey(sessionId);
const uploadState = uploadStatesBySession.get(sessionKey) || null;
- return uploadState?.inFlight === true ? uploadState : null;
+ // Once registered, individual attachments own readiness, not the batch promise.
+ return uploadState?.inFlight === true && !uploadState.itemsRegistered ? uploadState : null;
}
function normaliseTrustedUploadedFileCopyConceptId(value) {
@@ -21569,7 +21567,7 @@ function refreshConversationImages() {
syncConversationAttachmentWorkflowBinding(activeChatSessionId);
renderPendingAttachmentState();
});
- updateSendButtonForCurrentChatState();
+ renderActiveUploadUi();
}
function syncConversationAttachmentWorkflowBinding(sessionId) {
@@ -21611,24 +21609,6 @@ function renderPendingAttachmentState() {
refreshAttachmentStatusRegionVisibility();
}
-function rememberPendingUploadedFileCopyConceptId(
- conceptId,
- sessionId = activeChatSessionId,
- displayName = null
-) {
- const normalisedConceptId = normaliseTrustedUploadedFileCopyConceptId(conceptId);
- if (!normalisedConceptId) return;
- const targetKey = getPendingFileCopySessionKey(sessionId);
- pendingFileCopyConceptIdsBySession.set(targetKey, normalisedConceptId);
- const normalisedDisplayName = normalisePendingAttachmentDisplayName(displayName);
- if (normalisedDisplayName) {
- pendingFileCopyDisplayNamesBySession.set(targetKey, normalisedDisplayName);
- } else {
- pendingFileCopyDisplayNamesBySession.delete(targetKey);
- }
- renderPendingAttachmentState();
-}
-
function takePendingUploadedFileCopyBinding(sessionId) {
const targetKey = getPendingFileCopySessionKey(sessionId);
const targetConceptId = pendingFileCopyConceptIdsBySession.get(targetKey) || null;
@@ -21711,11 +21691,18 @@ function renderUploadStatus(message, type = 'info') {
function renderActiveUploadUi() {
const activeSessionKey = getPendingFileCopySessionKey(activeChatSessionId);
const activeUploadState = uploadStatesBySession.get(activeSessionKey) || null;
- renderUploadStatus(
- activeUploadState?.message || '',
- activeUploadState?.tone || 'info'
- );
- setUploadButtonBusy(activeUploadState?.inFlight === true);
+ const items = imageItems(activeChatSessionId);
+ const unfinished = items.filter(item => item.state !== 'ready');
+ const preparing = !!getInFlightUploadStateForSession(activeChatSessionId);
+ const uploading = unfinished.some(item => ['queued', 'uploading', 'retrying'].includes(item.state));
+ const failed = unfinished.some(item => item.state === 'failed');
+ const message = preparing ? activeUploadState.message
+ : failed ? attachmentBlockMessage(activeChatSessionId)
+ : uploading ? `Uploading: ${unfinished.map(item => item.name).join(', ')}`
+ : items.length ? `Upload complete: ${items.length} ready.`
+ : activeUploadState?.tone === 'error' ? activeUploadState.message : '';
+ renderUploadStatus(message, failed ? 'error' : (preparing || uploading) ? 'uploading' : 'success');
+ setUploadButtonBusy(preparing || uploading);
updateSendButtonForCurrentChatState();
}
@@ -21930,29 +21917,14 @@ async function performUploadFilesToVon(list, uploadState) {
rekeyUploadStateForSession(uploadState, uploadTargetSessionId);
}
- let successCount = 0;
- let failureCount = 0;
-
- for (const file of list) {
- const uploaded = await uploadConversationImage(file, uploadTargetSessionId, buildChatFetchHeaders(), () => {
- syncConversationAttachmentWorkflowBinding(uploadTargetSessionId);
- renderPendingAttachmentState();
- });
- if (uploaded) {
- successCount += 1;
- if (!isConversationImageFile(file)) {
- const item = imageItems(uploadTargetSessionId).findLast(item => item.name === file.name && item.descriptor);
- if (item) rememberPendingUploadedFileCopyConceptId(item.descriptor.concept_id, uploadTargetSessionId, file.name);
- }
- } else failureCount += 1;
- }
-
- const summary = `Upload complete: ${successCount} succeeded${failureCount ? `, ${failureCount} failed` : ''}.`;
- setUploadStatusForState(
- uploadState,
- summary,
- failureCount ? 'error' : 'success'
- );
+ // Register all files before yielding so unstarted members cannot escape the gate.
+ const uploads = list.map(file => uploadConversationImage(file, uploadTargetSessionId, buildChatFetchHeaders(), () => {
+ syncConversationAttachmentWorkflowBinding(uploadTargetSessionId);
+ renderPendingAttachmentState();
+ }));
+ uploadState.itemsRegistered = true;
+ renderActiveUploadUi();
+ await Promise.all(uploads);
clearUploadStatusAfterDelay(uploadState);
}
@@ -21973,10 +21945,14 @@ function uploadFilesToVon(files) {
const sessionKey = getPendingFileCopySessionKey(targetSessionId);
const selectionFingerprint = buildUploadSelectionFingerprint(list);
const existingState = uploadStatesBySession.get(sessionKey) || null;
- if (existingState?.inFlight && existingState.promise) {
+ if (existingState?.promise && (getInFlightUploadStateForSession(targetSessionId)
+ || imageItems(targetSessionId).some(item => ['queued', 'uploading', 'retrying'].includes(item.state)))) {
const repeatedSelection = (
existingState.selectionFingerprint === selectionFingerprint
);
+ if (!repeatedSelection) {
+ showToast('Another upload is already in progress; this additional selection was not uploaded.', 'info');
+ }
setUploadStatusForState(
existingState,
repeatedSelection
@@ -37317,7 +37293,7 @@ async function handleSendPrompt(options = {}) {
directComposerSubmission
&& (getInFlightUploadStateForSession(targetSessionId) || imagesBlocked(targetSessionId))
) {
- showToast(ATTACHMENT_UPLOAD_SEND_BLOCK_MESSAGE, 'info');
+ showToast(attachmentBlockMessage(targetSessionId), 'info');
updateSendButtonForCurrentChatState();
return;
}
diff --git a/src/frontend/web/von_interface/static/js/utils/conversationImages.js b/src/frontend/web/von_interface/static/js/utils/conversationImages.js
index 41b37c96..44606952 100644
--- a/src/frontend/web/von_interface/static/js/utils/conversationImages.js
+++ b/src/frontend/web/von_interface/static/js/utils/conversationImages.js
@@ -1,8 +1,16 @@
// Durable attachment descriptors (historical image API); originals remain behind authenticated routes.
+// Draft lifecycle: queued -> uploading -> ready | failed; failed -> retrying
+// -> ready | failed. Removal is terminal and late transport results are ignored.
+// Descriptors are payload, while state alone owns rendering and send readiness.
const pending = new Map();
const known = new Map();
export function imageItems(sessionId) { return pending.get(sessionId) || []; }
-export function imagesBlocked(sessionId) { return imageItems(sessionId).some(x => !x.descriptor); }
+export function imagesBlocked(sessionId) { return imageItems(sessionId).some(x => x.state !== 'ready'); }
+export function attachmentBlockMessage(sessionId) {
+ return imageItems(sessionId).some(x => x.state === 'failed')
+ ? 'Attachment upload failed. Retry or remove the failed attachment before sending.'
+ : 'Wait for the attachment upload to finish before sending.';
+}
export function renderImageAttachments(parent, images) {
if (!parent || !Array.isArray(images) || !images.length) return;
const gallery = document.createElement('div');
@@ -68,7 +76,8 @@ export function renderImageComposer(parent, sessionId, changed) {
for (const item of imageItems(sessionId)) {
const card = document.createElement('div');
card.className = 'conversation-image-draft';
- if (item.descriptor) {
+ card.dataset.attachmentState = item.state;
+ if (item.state === 'ready') {
renderImageAttachments(card, [item.descriptor]);
card.querySelectorAll('img').forEach(img => { img.style.maxHeight = '100px'; });
}
@@ -80,7 +89,7 @@ export function renderImageComposer(parent, sessionId, changed) {
card.appendChild(preview);
}
const label = document.createElement('span');
- label.textContent = item.error ? `${item.name}: ${item.error}` : (item.descriptor ? item.name : `Uploading ${item.name}…`);
+ label.textContent = item.error ? `${item.name}: ${item.error}` : (item.state === 'ready' ? `Ready: ${item.name}` : `${item.state === 'retrying' ? 'Retrying' : 'Uploading'} ${item.name}…`);
label.setAttribute('role', item.error ? 'alert' : 'status');
card.appendChild(label);
const details = document.createElement('span');
@@ -96,6 +105,7 @@ export function renderImageComposer(parent, sessionId, changed) {
const remove = document.createElement('button');
remove.type = 'button'; remove.textContent = 'Remove'; remove.setAttribute('aria-label', `Remove ${item.name}`);
remove.onclick = () => {
+ item.state = 'removed';
item.controller?.abort();
pending.set(sessionId, imageItems(sessionId).filter(x => x !== item));
releasePreview(item);
@@ -109,7 +119,7 @@ export function isConversationImageFile(file) {
}
export async function uploadConversationImage(file, sessionId, headers, changed) {
const item = {
- name: file.name, type: file.type, size: file.size,
+ name: file.name, type: file.type, size: file.size, state: 'queued',
previewUrl: file.type.startsWith('image/') && typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null,
};
item.retry = () => uploadItem(item, file, sessionId, headers, changed);
@@ -121,8 +131,8 @@ function releasePreview(item) {
item.previewUrl = null;
}
async function uploadItem(item, file, sessionId, headers, changed) {
- if (item.uploading || !imageItems(sessionId).includes(item)) return false;
- item.uploading = true;
+ if (['uploading', 'retrying', 'ready', 'removed'].includes(item.state) || !imageItems(sessionId).includes(item)) return false;
+ item.state = item.state === 'failed' ? 'retrying' : 'uploading';
item.controller = new AbortController();
item.error = null;
changed();
@@ -143,16 +153,23 @@ async function uploadItem(item, file, sessionId, headers, changed) {
const descriptor = result.image_attachment || (result.uploaded?.concept_id ? {...result.uploaded, filename: file.name, content_type: file.type || 'application/octet-stream', size_bytes: file.size} : null);
if (!response.ok || !descriptor) throw new Error(result.message || result.error || 'Attachment upload failed');
// Removing an in-flight upload must not restore it when the response arrives.
- if (imageItems(sessionId).includes(item)) {
+ if (item.state !== 'removed' && imageItems(sessionId).includes(item)) {
+ if (typeof descriptor.concept_id !== 'string' || !/^#V#\S+$/.test(descriptor.concept_id)) throw new Error('Upload returned no valid attachment identifier');
item.descriptor = descriptor;
+ item.state = 'ready';
item.retry = () => Promise.resolve(true);
known.set(item.descriptor.concept_id, item);
}
releasePreview(item);
- } catch (error) { item.error = String(error.message || error); }
- item.uploading = false;
+ } catch (error) {
+ if (item.state !== 'removed' && item.state !== 'ready') {
+ item.state = 'failed';
+ item.error = String(error.message || error);
+ }
+ }
+ item.controller = null;
changed();
- return Boolean(item.descriptor);
+ return item.state === 'ready';
}
// Both controls use the existing session-bound upload path supplied by chatTab.
export function initialiseImagePicker(upload, status) {
diff --git a/tests/browser/attachmentLifecycle.cjs b/tests/browser/attachmentLifecycle.cjs
new file mode 100644
index 00000000..0408ea20
--- /dev/null
+++ b/tests/browser/attachmentLifecycle.cjs
@@ -0,0 +1,57 @@
+// Local Chromium component acceptance: actual attachment module, fixture transport.
+// Does not use server authentication, upload real data, or invoke a model.
+process.env.PLAYWRIGHT_BROWSERS_PATH ||= '/tmp/von-playwright';
+const { chromium, expect } = require('@playwright/test');
+const fs = require('node:fs');
+const path = require('node:path');
+const output = process.argv[2] || '.run/attachment-lifecycle';
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage();
+ await page.route('http://attachment.test/**', route => {
+ if (route.request().url().endsWith('/module.js')) return route.fulfill({
+ contentType: 'text/javascript',
+ body: fs.readFileSync(path.join(__dirname, '../../src/frontend/web/von_interface/static/js/utils/conversationImages.js'), 'utf8')
+ });
+ return route.fulfill({ contentType: 'text/html', body: '