From 77d6b0edfd431ab7d9cc89822f7e8b89cd0c9b85 Mon Sep 17 00:00:00 2001 From: witbrock Date: Wed, 16 Sep 2026 15:27:31 -0400 Subject: [PATCH] Scope conversation drafts and clear submitted image captions after navigation --- .../web/von_interface/static/js/chatTab.js | 44 +++++-- tests/browser/composerDraftLifecycle.cjs | 122 ++++++++++++++++++ .../chatAttachmentWorkflowInput.test.js | 108 ++++++++++++++++ 3 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 tests/browser/composerDraftLifecycle.cjs diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index bf935a08..e4eeca86 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -189,6 +189,9 @@ const turnEditHistory = new Map(); let historySegmentsShown = 1; let totalHistorySegments = 1; let activeChatSessionId = null; +// Unsent text belongs to a conversation in this tab, not the shared textarea. +// It is not history and is not automatically restored after a page reload. +const composerDraftsBySession = new Map(); let turnModelPicker = null; let activeChatSessionName = null; let activeChatSessionOwnerId = null; @@ -21327,6 +21330,14 @@ function dispatchPromptComposerInputEvent(promptInput = getPromptInputElement()) } } +function clearSubmittedComposerDraft(sessionId, submittedText) { + const input = activeChatSessionId === sessionId ? getPromptInputElement() : null; + const draft = input ? input.value : composerDraftsBySession.get(sessionId); + if (draft !== submittedText) return; + composerDraftsBySession.delete(sessionId); + if (input) setPromptComposerValue(''); +} + function setPromptComposerValue(value, options = {}) { const promptInput = options.promptInput || getPromptInputElement(); if (!promptInput) { @@ -21334,6 +21345,7 @@ function setPromptComposerValue(value, options = {}) { } promptInput.value = typeof value === 'string' ? value : ''; + composerDraftsBySession.set(activeChatSessionId, promptInput.value); if (!promptInput.value) dictationController?.clearAttemptIds?.(); dispatchPromptComposerInputEvent(promptInput); @@ -25081,6 +25093,8 @@ function updateHistoryBanner() { function setActiveChatSession(sessionId, sessionName, externalConversation = undefined) { const previousSessionId = activeChatSessionId; + const composer = getPromptInputElement(); + if (composer) composerDraftsBySession.set(previousSessionId, composer.value); activeChatSessionId = (typeof sessionId === 'string' && sessionId.trim()) ? sessionId.trim() : null; @@ -25104,6 +25118,12 @@ function setActiveChatSession(sessionId, sessionName, externalConversation = und renderActiveChatSessionFocusChips(activeChatSessionFocus); if (previousSessionId !== activeChatSessionId) { + // The first session adopts an unbound draft (e.g. an initial upload). + const draft = composerDraftsBySession.get(activeChatSessionId) + ?? (previousSessionId === null ? composerDraftsBySession.get(null) : '') + ?? ''; + composerDraftsBySession.delete(null); + setPromptComposerValue(draft); if (previousSessionId) turnModelPicker?.clear(); setSelectedChatPromptQueueEntryId(null); chatSessionSelectionGeneration += 1; @@ -28419,6 +28439,8 @@ async function createChatSession(sessionName, options = {}) { throw new Error(msg); } + const preservedComposerValue = options.preserveComposerDraft + ? String(getPromptInputElement()?.value || '') : ''; setActiveChatSession(data?.session_id, data?.session_name); const createdFocus = getFocusFromPayload(data); if (data?.session_id) { @@ -28493,10 +28515,7 @@ async function createChatSession(sessionName, options = {}) { }); } - const composerValue = options.preserveComposerDraft - ? String(getPromptInputElement()?.value || '') - : ''; - setPromptComposerValue(composerValue, { focus: true }); + setPromptComposerValue(preservedComposerValue, { focus: true }); document.dispatchEvent(new CustomEvent('von:contextReset', { detail: { trigger: 'chat_new_session', session_id: effectiveSessionId, session_name: effectiveName || null } @@ -33591,6 +33610,8 @@ function startOrganisationSwitchForChatTab(detail = {}) { transcriptTurns.length = 0; turnEditHistory.clear(); clearLlmDebugDataEntries(); + setPromptComposerValue(''); + composerDraftsBySession.clear(); setActiveChatSession(null, null); displayedHistorySessionId = null; historySegmentsShown = 0; @@ -34334,9 +34355,13 @@ export function initializeChatTab() { setSelectedChatPromptQueueEntryId(null); }); + if (composerDraftsBySession.has(activeChatSessionId)) { + setPromptComposerValue(composerDraftsBySession.get(activeChatSessionId), { promptInput }); + } initialiseConversationDraft({ input: promptInput, sendButton, onSubmit: submitComposer, onInput: () => { + composerDraftsBySession.set(activeChatSessionId, promptInput.value); setSelectedChatPromptQueueEntryId(null); updateSendButtonDraftReadiness(); }, @@ -37355,7 +37380,7 @@ async function handleSendPrompt(options = {}) { }); refreshConversationImages(); rememberLastSubmittedUserPrompt(promptRaw); - setPromptComposerValue('', { promptInput }); + clearSubmittedComposerDraft(targetSessionId, promptRaw); updateSendButtonForCurrentChatState(); try { await queuePromptForLater(promptRaw, { @@ -37596,8 +37621,10 @@ async function handleSendPrompt(options = {}) { if (!claimForegroundDelivery()) { return false; } - if (request.executionEnvelope?.image_attachment_ids?.length && isRequestVisible() && promptInput.value === request.promptRaw) { - setPromptComposerValue('', { promptInput }); + if (!fromQueue && request.executionEnvelope?.image_attachment_ids?.length) { + // Completion can arrive after a switch or an editor remount. Clear + // only the submitted draft, never a newer edit or another session. + clearSubmittedComposerDraft(request.sessionId, request.promptRaw); } _acceptConversationSituationPayload(data, request.sessionId); const responsePresenterState = resolveResponsePresenterState(data); @@ -37876,7 +37903,7 @@ async function handleSendPrompt(options = {}) { if (promptText) { rememberLastSubmittedUserPrompt(promptRaw); } - if (!request.executionEnvelope?.image_attachment_ids?.length) setPromptComposerValue('', { promptInput }); + if (!request.executionEnvelope?.image_attachment_ids?.length) clearSubmittedComposerDraft(targetSessionId, promptRaw); } let generateTransportInterrupted = false; @@ -42061,6 +42088,7 @@ export function __testOnly_resetChatRequestState() { lastFinishedThinkingCard = null; newChatCreationInFlight = null; activeChatSessionId = null; + composerDraftsBySession.clear(); activeChatSessionName = null; activeChatSessionOwnerId = null; chatSessionFocusCache.clear(); diff --git a/tests/browser/composerDraftLifecycle.cjs b/tests/browser/composerDraftLifecycle.cjs new file mode 100644 index 00000000..27c58d6e --- /dev/null +++ b/tests/browser/composerDraftLifecycle.cjs @@ -0,0 +1,122 @@ +// Composer state acceptance using production templates, CSS and send/switch code. +// No authentication, live messages, database writes, or model calls are exercised. +// PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/composerDraftLifecycle.cjs DIR +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, expect } = require('@playwright/test'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const evidence = process.argv[2] || '.run/composer-evidence/browser'; +fs.mkdirSync(evidence, { recursive: true }); +function template(name) { + return fs.readFileSync(path.join(root, 'templates', name), 'utf8') + .replace(/{% include '([^']+)' %}/g, (_, child) => template(child)) + .replace(/]*>[\s\S]*?<\/script>/g, '') + .replace(/{{ url_for\('static', filename='([^']+)'\) }}/g, '/static/$1') + .replace(/{%[\s\S]*?%}|{{[\s\S]*?}}/g, ''); +} +const server = http.createServer((req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname; + if (pathname === '/') { res.setHeader('Content-Type', 'text/html'); return res.end(template('von_interface.html')); } + const file = path.resolve(root, `.${pathname}`); + if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) return res.writeHead(404).end(); + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : file.endsWith('.png') ? 'image/png' : '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({ headless: true }); + const results = []; + try { + for (const width of [1440, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + let finishGenerate; + let rejectNext = false; + const sent = []; + await page.route('**/*', async route => { + const url = new URL(route.request().url()); + if (!url.pathname.includes('/api/') && !['/von/generate', '/von/history', '/von/history/length', '/von/progress'].includes(url.pathname)) return route.continue(); + let data = {}; + if (url.pathname === '/von/generate') { + sent.push(route.request().postDataJSON()); + if (rejectNext) { + rejectNext = false; + return route.fulfill({ status: 400, json: { error: 'Fixture rejection' } }); + } + await new Promise(resolve => { finishGenerate = resolve; }); + finishGenerate = null; + data = { response: 'Fixture received the caption.', llm_debug: { model: 'fixture-no-model-call' } }; + } else if (url.pathname === '/von/api/images/upload') data = { image_attachment: { concept_id: '#V#fixture_image', filename: 'attachment.png' } }; + else if (url.pathname === '/von/api/session/set_chat_session') data = { session_id: route.request().postDataJSON().session_id }; + else if (url.pathname === '/von/history') data = { history: [], session_id: url.searchParams.get('session_id'), authenticated: true }; + else if (url.pathname === '/von/history/length') data = { history_length: 0, authenticated: true }; + else if (url.pathname === '/von/api/render_markdown') data = { html: route.request().postDataJSON().text || '' }; + await route.fulfill({ json: data }); + }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(async () => { + document.body.classList.remove('von-auth-pending'); + document.querySelector('#vonAuthenticationGate').hidden = true; + document.querySelector('#vonAuthenticatedApp').hidden = false; + localStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#fixture_user' })); + window.chat = await import('/static/js/chatTab.js'); + const { initialiseConversationDraft } = await import('/static/js/components/conversationDraft.js'); + chat.__testOnly_setActiveChatSession('first', 'First'); + initialiseConversationDraft({ input: document.querySelector('#promptInput'), sendButton: document.querySelector('#sendButton'), onSubmit: () => { window.pendingSend = chat.sendMessage(); } }); + }); + const input = page.locator('#promptInput'); + const select = session => page.evaluate(session => chat.switchToChatSession(session), session); + const upload = () => page.evaluate(() => chat.__testOnly_uploadFilesToVon([new File(['fixture'], 'attachment.png', { type: 'image/png' })])); + const caption = process.env.VON_COMPOSER_REPRO_FILE + ? fs.readFileSync(process.env.VON_COMPOSER_REPRO_FILE, 'utf8') + : 'Submitted multiline caption.\nPlease inspect the attached footer.'; + await input.fill('First unsent draft'); + await select('second'); + await expect(input).toHaveValue(''); + await input.fill('Second unsent draft'); + await select('first'); + await expect(input).toHaveValue('First unsent draft'); + await upload(); + await input.fill(caption); + await page.locator('#sendButton').click(); + await expect.poll(() => Boolean(finishGenerate)).toBe(true); + await select('second'); + await expect(input).toHaveValue('Second unsent draft'); + finishGenerate(); + await page.evaluate(() => pendingSend); + await expect(input).toHaveValue('Second unsent draft'); + await select('first'); + await expect(input).toHaveValue(''); + await upload(); + await input.fill('Retry caption'); + rejectNext = true; + await page.locator('#sendButton').click(); + await page.evaluate(() => pendingSend); + await expect(input).toHaveValue('Retry caption'); + await select('second'); + await expect(input).toHaveValue('Second unsent draft'); + await select('first'); + await expect(input).toHaveValue('Retry caption'); + await page.locator('#sendButton').click(); + await expect.poll(() => Boolean(finishGenerate)).toBe(true); + finishGenerate(); + await page.evaluate(() => pendingSend); + await expect(input).toHaveValue(''); + assert.equal(sent.length, 3); + assert.equal(sent[0].prompt, caption); + assert.deepEqual(sent[1].image_attachment_ids, sent[2].image_attachment_ids); + await page.screenshot({ path: path.join(evidence, `${width}-cleared.png`) }); + await page.reload(); + await expect(input).toHaveValue(''); + results.push({ width, background_send_cleared: true, draft_isolation: true, failure_retry: true, reload_empty: true, requests: sent.length }); + await page.close(); + } + fs.writeFileSync(path.join(evidence, 'results.json'), JSON.stringify({ mode: 'production modules with local HTTP fixtures; no live authentication or model calls', results }, null, 2)); + console.log(JSON.stringify(results)); + } finally { + await browser.close(); + await new Promise(resolve => server.close(resolve)); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/frontend/chatAttachmentWorkflowInput.test.js b/tests/frontend/chatAttachmentWorkflowInput.test.js index 1237e862..4513bf30 100644 --- a/tests/frontend/chatAttachmentWorkflowInput.test.js +++ b/tests/frontend/chatAttachmentWorkflowInput.test.js @@ -77,6 +77,114 @@ describe('chat attachment workflow input binding', () => { delete global.fetch; }); + test.each(['visible', 'background', 'remounted', 'edited'])( + 'successful image submission clears only its own unchanged draft: %s', async mode => { + const { __testOnly_uploadFilesToVon, __testOnly_setActiveChatSession: select, sendMessage } = require(chatTabModulePath); + let finish; + global.fetch = jest.fn(async (url, options = {}) => { + if (url === '/von/api/images/upload') return { ok: true, json: async () => ({ image_attachment: { concept_id: '#V#draft-image', filename: 'draft.png' } }) }; + if (url === '/von/generate') 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 () => ({}) }; + }); + await __testOnly_uploadFilesToVon([new File(['png'], 'draft.png', { type: 'image/png' })]); + let input = document.getElementById('promptInput'); + input.value = 'Submitted caption\nwith multiple lines.'; + const pending = sendMessage(); + for (let i = 0; i < 50 && !finish; i++) await new Promise(resolve => setTimeout(resolve, 5)); + expect(finish).toBeDefined(); + if (mode === 'background') { + select('other-session', 'Other'); + expect(input.value).toBe(''); + input.value = 'Other conversation draft'; + } else if (mode === 'remounted') { + const replacement = input.cloneNode(true); + replacement.value = input.value; + input.replaceWith(replacement); + input = replacement; + } else if (mode === 'edited') input.value = 'Next intentional draft'; + finish({ ok: true, json: async () => ({ response: 'Received.', llm_debug: { model: 'test-model' } }) }); + await pending; + if (mode === 'background') { + expect(input.value).toBe('Other conversation draft'); + select('attachment-session', 'Attachment test'); + } + expect(input.value).toBe(mode === 'edited' ? 'Next intentional draft' : ''); + select('other-session', 'Other'); + expect(input.value).toBe(mode === 'background' ? 'Other conversation draft' : ''); + select('attachment-session', 'Attachment test'); + expect(input.value).toBe(mode === 'edited' ? 'Next intentional draft' : ''); + } + ); + + 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 () => { + if (url === '/von/generate') return { response: 'Received.', llm_debug: { model: 'test-model' } }; + if (String(url).startsWith('/von/api/render_markdown')) return { html: JSON.parse(options.body).text || '' }; + if (String(url).startsWith('/von/history/length')) return { history_length: 0, authenticated: true }; + return {}; + } })); + const input = document.getElementById('promptInput'); + input.value = 'Text-only prompt'; + await sendMessage(); + expect(input.value).toBe(''); + select('other-session', 'Other'); + input.value = 'Unsent follow-up'; + select('attachment-session', 'Attachment test'); + expect(input.value).toBe(''); + select('other-session', 'Other'); + expect(input.value).toBe('Unsent follow-up'); + }); + + test('unsent drafts are isolated across navigation and cleared on organisation change', () => { + const { __testOnly_setActiveChatSession: select, __testOnly_startOrganisationSwitchForChatTab: switchOrg } = require(chatTabModulePath); + const input = document.getElementById('promptInput'); + input.value = 'First draft'; + select('second', 'Second'); + expect(input.value).toBe(''); + input.value = 'Second draft'; + select('attachment-session', 'Attachment test'); + expect(input.value).toBe('First draft'); + select('second', 'Second'); + expect(input.value).toBe('Second draft'); + switchOrg({ switch_id: 'draft-org-switch' }); + expect(input.value).toBe(''); + select('attachment-session', 'Attachment test'); + expect(input.value).toBe(''); + }); + + test('failed image send retains its scoped caption and attachment for a successful retry', async () => { + const { __testOnly_uploadFilesToVon, __testOnly_setActiveChatSession: select, sendMessage } = require(chatTabModulePath); + const bodies = []; + global.fetch = jest.fn(async (url, options = {}) => { + if (url === '/von/api/images/upload') return { ok: true, json: async () => ({ image_attachment: { concept_id: '#V#retry-draft-image', filename: 'retry.png' } }) }; + if (url === '/von/generate') { + bodies.push(JSON.parse(options.body)); + return bodies.length === 1 + ? { ok: false, status: 400, json: async () => ({ error: 'Fixture rejection' }) } + : { 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 () => ({}) }; + }); + await __testOnly_uploadFilesToVon([new File(['png'], 'retry.png', { type: 'image/png' })]); + const input = document.getElementById('promptInput'); + input.value = 'Retain this caption'; + await sendMessage(); + expect(input.value).toBe('Retain this caption'); + select('other-session', 'Other'); + expect(input.value).toBe(''); + select('attachment-session', 'Attachment test'); + expect(input.value).toBe('Retain this caption'); + await sendMessage(); + expect(input.value).toBe(''); + expect(bodies).toHaveLength(2); + expect(bodies[1].image_attachment_ids).toEqual(['#V#retry-draft-image']); + }); + test('removing the second uploading PDF unblocks the ready PDF before late completion', async () => { const { __testOnly_uploadFilesToVon, sendMessage } = require(chatTabModulePath); const { imageItems } = require('../../src/frontend/web/von_interface/static/js/utils/conversationImages.js');