diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js
index ec4e1bfc..2da77749 100644
--- a/src/frontend/web/von_interface/static/js/chatTab.js
+++ b/src/frontend/web/von_interface/static/js/chatTab.js
@@ -34342,6 +34342,7 @@ export function initializeChatTab() {
// Add event listeners
const submitComposer = (event) => {
+ if (isComposerDraftSubmissionInFlight()) return;
updateSendButtonForCurrentChatState();
if (!chatSteeringControls?.activate(event)) void handleSendPrompt();
};
@@ -37208,7 +37209,46 @@ function getQueuedChatPromptHeadsBySession() {
return heads;
}
+const composerSubmissionsInFlight = new Set();
+
+function isComposerDraftSubmissionInFlight(input = getPromptInputElement()) {
+ return input && [...composerSubmissionsInFlight].some(claim => (
+ claim.sessionId === activeChatSessionId
+ && claim.generation === chatOrganisationGeneration
+ && claim.text === input.value
+ ));
+}
+
async function handleSendPrompt(options = {}) {
+ const input = getPromptInputElement();
+ const direct = input && !options.fromQueue && !options.observeServerDispatch
+ && typeof options.promptOverride !== 'string'
+ && !getSelectedChatPromptQueueEntryForSend();
+ if (!direct) return performSendPrompt(options);
+
+ // Claim the draft before session creation or dictation can yield. A live
+ // request alone is insufficient: a repeated activation would queue the
+ // same draft (including text retained for attachment recovery).
+ const sessionId = activeChatSessionId;
+ const generation = chatOrganisationGeneration;
+ if (isComposerDraftSubmissionInFlight(input)) return;
+ const claim = { sessionId, generation, text: input.value };
+ composerSubmissionsInFlight.add(claim);
+ const releaseEditedDraft = () => {
+ if (activeChatSessionId === claim.sessionId && input.value !== claim.text) {
+ composerSubmissionsInFlight.delete(claim);
+ }
+ };
+ input.addEventListener('input', releaseEditedDraft);
+ try {
+ return await performSendPrompt(options, claim);
+ } finally {
+ input.removeEventListener('input', releaseEditedDraft);
+ composerSubmissionsInFlight.delete(claim);
+ }
+}
+
+async function performSendPrompt(options = {}, composerClaim = null) {
if (!options?.fromQueue && !options?.observeServerDispatch && chatSteeringControls?.isSending()) return;
if (pendingChatOrganisationSwitchId !== null) {
return;
@@ -37357,6 +37397,12 @@ async function handleSendPrompt(options = {}) {
return;
}
+ if (composerClaim) {
+ composerClaim.sessionId = targetSessionId;
+ composerClaim.text = promptRaw;
+ composerSubmissionsInFlight.add(composerClaim);
+ }
+
if (
!observeServerDispatch
&& isChatPromptSessionBusy(targetSessionId, { includeQueued: !fromQueue })
@@ -42087,6 +42133,7 @@ export function __testOnly_resetChatRequestState() {
chatPromptQueuePollInFlight = false;
queuedChatPromptClaimsInFlight.clear();
queuedChatPromptSubmissionsInFlight.clear();
+ composerSubmissionsInFlight.clear();
serverDispatchedRetryObserversInFlight.clear();
voicePromptQueueIds.clear();
chatPromptQueueAdmissionServerInstanceId = null;
diff --git a/tests/browser/chatDuplicateSubmission.cjs b/tests/browser/chatDuplicateSubmission.cjs
new file mode 100644
index 00000000..487e3862
--- /dev/null
+++ b/tests/browser/chatDuplicateSubmission.cjs
@@ -0,0 +1,72 @@
+// Real chat send and composer modules, isolated HTTP fixtures; no provider calls.
+// PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/chatDuplicateSubmission.cjs
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const http = require('node:http');
+const path = require('node:path');
+const { chromium } = require('@playwright/test');
+const root = path.resolve(__dirname, '../../src/frontend/web/von_interface');
+const output = process.argv[2] || '.run/duplicate-send/browser';
+fs.mkdirSync(output, { recursive: true });
+const markup = fs.readFileSync(path.join(root, 'templates/chat_tab.html'), 'utf8').replace(/{%[\s\S]*?%}/g, '').replace(/{{[\s\S]*?}}/g, '');
+const server = http.createServer((req, res) => {
+ if (req.url === '/') return res.end(`${markup}`);
+ const file = path.resolve(root, `.${req.url.split('?')[0]}`);
+ if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) return res.writeHead(404).end();
+ res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript');
+ res.end(fs.readFileSync(file));
+});
+(async () => {
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ const browser = await chromium.launch();
+ const evidence = [];
+ try {
+ for (const width of [1440, 375]) {
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.goto(`http://127.0.0.1:${server.address().port}/`);
+ await page.evaluate(async () => {
+ window.queueBodies = []; window.generateBodies = [];
+ window.fetch = async (url, options = {}) => {
+ const body = JSON.parse(options.body && typeof options.body === 'string' ? options.body : '{}');
+ let data = {};
+ if (url === '/von/api/images/upload') data = { image_attachment: { concept_id: '#V#fixture-image', filename: 'fixture.png' } };
+ else if (url === '/von/api/chat_prompt_queue' && options.method === 'POST') {
+ queueBodies.push(body);
+ data = { success: true, item: { ...body, queue_id: `queue-${queueBodies.length}`, status: 'queued' } };
+ } else if (url === '/von/generate') {
+ generateBodies.push(body);
+ return new Promise(resolve => { window.finishGenerate = () => resolve(new Response(JSON.stringify({ response: 'Received.', llm_debug: { model: 'fixture' } }))); });
+ } else if (String(url).includes('render_markdown')) data = { html: body.text || '' };
+ else if (String(url).includes('history/length')) data = { history_length: 0, authenticated: true };
+ else data = { items: [], history: [] };
+ return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
+ };
+ window.chat = await import('/static/js/chatTab.js');
+ chat.__testOnly_setActiveChatSession('fixture', 'Duplicate submission fixture');
+ const { initialiseConversationDraft } = await import('/static/js/components/conversationDraft.js');
+ const input = document.querySelector('#promptInput');
+ initialiseConversationDraft({ input, sendButton: document.querySelector('#sendButton'), onSubmit: () => { window.pending = chat.sendMessage(); } });
+ await chat.__testOnly_uploadFilesToVon([new File(['png'], 'fixture.png', { type: 'image/png' })]);
+ });
+ await page.locator('#promptInput').fill('Describe this image once');
+ await page.locator('#sendButton').dblclick();
+ await page.waitForFunction(() => generateBodies.length === 1);
+ if (width > 800) await page.locator('#promptInput').press('Enter');
+ else await page.locator('#sendButton').click();
+ assert.deepEqual(await page.evaluate(() => [queueBodies.length, generateBodies.length]), [1, 1]);
+ await page.screenshot({ path: path.join(output, `${width}-single-submission.png`) });
+ await page.locator('#promptInput').fill('A deliberate follow-up');
+ await page.locator('#sendButton').click();
+ await page.waitForFunction(() => queueBodies.length === 2);
+ assert.equal(await page.evaluate(() => queueBodies[1].prompt_raw), 'A deliberate follow-up');
+ await page.evaluate(() => finishGenerate());
+ assert.deepEqual(errors, []);
+ evidence.push({ width, generated: 1, admittedOriginal: 1, admittedFollowUp: 1, errors });
+ await page.close();
+ }
+ fs.writeFileSync(path.join(output, 'results.json'), JSON.stringify(evidence, null, 2));
+ console.log(JSON.stringify(evidence));
+ } finally { await browser.close(); server.close(); }
+})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });
diff --git a/tests/frontend/chatAttachmentWorkflowInput.test.js b/tests/frontend/chatAttachmentWorkflowInput.test.js
index 4513bf30..91641f93 100644
--- a/tests/frontend/chatAttachmentWorkflowInput.test.js
+++ b/tests/frontend/chatAttachmentWorkflowInput.test.js
@@ -118,6 +118,82 @@ describe('chat attachment workflow input binding', () => {
}
);
+ test('concurrent sends while a conversation is being prepared admit only one prompt', async () => {
+ const { __testOnly_setActiveChatSession: select, sendMessage } = require(chatTabModulePath);
+ select(null);
+ let finishCreation;
+ const queueBodies = [];
+ const generateBodies = [];
+ global.fetch = jest.fn(async (url, options = {}) => {
+ if (url === '/von/api/session/create_chat_session') return new Promise(resolve => { finishCreation = resolve; });
+ if (url === '/von/api/chat_prompt_queue' && options.method === 'POST') {
+ const body = JSON.parse(options.body);
+ queueBodies.push(body);
+ return { ok: true, json: async () => ({ success: true, item: { ...body, queue_id: `queue-${queueBodies.length}`, status: 'queued' } }) };
+ }
+ if (url === '/von/generate') {
+ generateBodies.push(JSON.parse(options.body));
+ return { ok: true, json: async () => ({ response: 'Received.', llm_debug: { model: 'test-model' } }) };
+ }
+ if (String(url).startsWith('/von/history/length')) return { ok: true, json: async () => ({ history_length: 0, authenticated: true }) };
+ if (String(url).startsWith('/von/api/render_markdown')) return { ok: true, json: async () => ({ html: JSON.parse(options.body).text || '' }) };
+ return { ok: true, json: async () => ({ items: [], history: [] }) };
+ });
+ document.getElementById('promptInput').value = 'First message';
+ const first = sendMessage();
+ const repeated = sendMessage();
+ for (let i = 0; i < 50 && !finishCreation; i++) await new Promise(resolve => setTimeout(resolve, 5));
+ expect(finishCreation).toBeDefined();
+ expect(global.fetch.mock.calls.filter(([url]) => url === '/von/api/session/create_chat_session')).toHaveLength(1);
+ finishCreation({ ok: true, json: async () => ({ success: true, session_id: 'created-session', history: [] }) });
+ await Promise.all([first, repeated]);
+ expect(queueBodies).toHaveLength(1);
+ expect(generateBodies).toHaveLength(1);
+ });
+
+ test.each(['unchanged', 'edited', 'retyped'])('rapid repeat does not enqueue an in-flight attachment draft: %s', async mode => {
+ const { __testOnly_uploadFilesToVon, sendMessage } = require(chatTabModulePath);
+ let finish;
+ const queueBodies = [];
+ const generateBodies = [];
+ global.fetch = jest.fn(async (url, options = {}) => {
+ if (url === '/von/api/images/upload') return { ok: true, json: async () => ({ image_attachment: { concept_id: '#V#repeat-image', filename: 'repeat.png' } }) };
+ if (url === '/von/api/chat_prompt_queue' && options.method === 'POST') {
+ const body = JSON.parse(options.body);
+ queueBodies.push(body);
+ return { ok: true, json: async () => ({ success: true, item: { ...body, queue_id: `queue-${queueBodies.length}`, status: 'queued' } }) };
+ }
+ if (url === '/von/generate') {
+ generateBodies.push(JSON.parse(options.body));
+ return new Promise(resolve => { finish = resolve; });
+ }
+ if (String(url).startsWith('/von/history/length')) return { ok: true, json: async () => ({ history_length: 0, authenticated: true }) };
+ if (String(url).startsWith('/von/api/render_markdown')) return { ok: true, json: async () => ({ html: JSON.parse(options.body).text || '' }) };
+ return { ok: true, json: async () => ({ items: [] }) };
+ });
+ await __testOnly_uploadFilesToVon([new File(['png'], 'repeat.png', { type: 'image/png' })]);
+ const input = document.getElementById('promptInput');
+ input.value = 'Describe this image';
+ const pending = sendMessage();
+ await sendMessage();
+ for (let i = 0; i < 50 && !finish; i++) await new Promise(resolve => setTimeout(resolve, 5));
+ expect(finish).toBeDefined();
+ await sendMessage();
+ expect(generateBodies).toHaveLength(1);
+ expect(queueBodies).toHaveLength(1);
+ expect(input.value).toBe('Describe this image');
+ if (mode !== 'unchanged') {
+ input.value = 'A different follow-up';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ if (mode === 'retyped') input.value = 'Describe this image';
+ await sendMessage();
+ expect(queueBodies).toHaveLength(2);
+ expect(queueBodies[1].prompt_raw).toBe(mode === 'retyped' ? 'Describe this image' : 'A different follow-up');
+ }
+ finish({ ok: true, json: async () => ({ response: 'Received.', llm_debug: { model: 'test-model' } }) });
+ await pending;
+ });
+
test('successful text-only submission stays cleared after switching away and back', async () => {
const { __testOnly_setActiveChatSession: select, sendMessage } = require(chatTabModulePath);
global.fetch = jest.fn(async (url, options = {}) => ({ ok: true, json: async () => {