From 3578fd625f6fbf4ce36945492c7b296a6f5bc9fb Mon Sep 17 00:00:00 2001 From: Gordon Date: Sun, 1 Nov 2026 16:54:20 +0800 Subject: [PATCH 1/3] feat(mobile-web): send a prompt suggestion with one tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suggestion bubble only accepts the text into the composer, so sending a suggestion always costs a second tap on the send button, and accepting first hides the bubble because it is gated on an empty editor. Keep the bubble as the accept affordance and add a trailing control that submits the suggestion directly. The send path reuses the existing submission handler with an explicit text override, so toast/queue/error handling stays in one place, and the draft is left untouched — a half-typed message must never be clobbered by a suggestion. Covered by the existing prompt-suggestion browser fixture plus a new case asserting one tap records a send and does not fill the textarea. iOS, Android and HarmonyOS composers still accept-only; this change covers the Remote web surface. --- .../src/components/ChatComposerBar.tsx | 35 +++++++++++------ src/mobile-web/src/i18n/messages.ts | 3 ++ src/mobile-web/src/pages/ChatPage.tsx | 21 +++++++--- .../src/styles/components/chat-input.scss | 14 +++++++ .../tests/fixtures/prompt-suggestion.tsx | 2 + .../tests/prompt-suggestion-browser.test.mjs | 39 +++++++++++++++++++ 6 files changed, 97 insertions(+), 17 deletions(-) diff --git a/src/mobile-web/src/components/ChatComposerBar.tsx b/src/mobile-web/src/components/ChatComposerBar.tsx index 7fdfc3fc23..7d3918183f 100644 --- a/src/mobile-web/src/components/ChatComposerBar.tsx +++ b/src/mobile-web/src/components/ChatComposerBar.tsx @@ -35,6 +35,7 @@ interface ChatComposerBarProps { pendingImages: PendingImage[]; promptSuggestion?: string | null; onAcceptPromptSuggestion?: (suggestion: string) => void; + onSendPromptSuggestion?: (suggestion: string) => void; remoteUnavailable: boolean; streaming: boolean; } @@ -61,6 +62,7 @@ export default function ChatComposerBar({ pendingImages, promptSuggestion, onAcceptPromptSuggestion, + onSendPromptSuggestion, remoteUnavailable, streaming, }: ChatComposerBarProps) { @@ -74,17 +76,28 @@ export default function ChatComposerBar({
{queueContent} {visibleSuggestion ? ( - onAcceptPromptSuggestion?.(visibleSuggestion)} - size="sm" - trailing={ +
+ onAcceptPromptSuggestion?.(visibleSuggestion)} + size="sm" + trailing={ +
) : null} = { collapsedInputPlaceholder: 'Ask OpenBitFun', collapsedStreamingPlaceholder: 'Replying...', promptSuggestionAccept: 'Use suggestion: {suggestion}', + promptSuggestionSend: 'Send suggestion: {suggestion}', messageQueued: 'Message queued; it will run after the current step', imageAnalyzingPlaceholder: 'Sending images...', imageAttachmentFallback: '(see attached images)', @@ -627,6 +628,7 @@ export const messages: Record = { collapsedInputPlaceholder: '向 OpenBitFun 提问', collapsedStreamingPlaceholder: '正在回复...', promptSuggestionAccept: '采纳建议:{suggestion}', + promptSuggestionSend: '直接发送建议:{suggestion}', messageQueued: '已加入队列,将在当前步骤结束后处理', imageAnalyzingPlaceholder: '正在发送图片...', imageAttachmentFallback: '(见附带图片)', @@ -995,6 +997,7 @@ export const messages: Record = { collapsedInputPlaceholder: '向 OpenBitFun 提問', collapsedStreamingPlaceholder: '正在回覆...', promptSuggestionAccept: '採納建議:{suggestion}', + promptSuggestionSend: '直接傳送建議:{suggestion}', messageQueued: '已加入隊列,將在當前步驟結束後處理', imageAnalyzingPlaceholder: '正在傳送圖片...', imageAttachmentFallback: '(見附帶圖片)', diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 95677c6b22..c2c9554842 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -980,9 +980,13 @@ const ChatPage: React.FC = ({ return () => clearInterval(tid); }, [isStreaming]); - const handleSend = useCallback(async () => { + const handleSend = useCallback(async (overrideText?: string) => { if (useMobileStore.getState().connectionHealth === 'unreachable') return; - const text = input.trim(); + // `onClick` passes the click event here; only a string means "submit this + // text instead of the current draft", which is how a suggested prompt is + // sent in one tap. + const explicitText = typeof overrideText === 'string' ? overrideText : null; + const text = (explicitText ?? input).trim(); const imgs = pendingImages; if ((!text && imgs.length === 0) || imageAnalyzing || sendInFlightRef.current) return; const targetEpoch = captureChatTargetEpoch(); @@ -1026,11 +1030,15 @@ const ChatPage: React.FC = ({ if (!isChatTargetCurrent(targetEpoch)) return; // Keep the draft until the host acknowledges it. A failed request must // remain retryable, and an acknowledgement must not erase newer typing. - const draftUnchanged = inputRef.current?.value === input; - setInput(current => current === input ? '' : current); + // A suggestion sent in one tap never touched the draft, so it must leave + // whatever the user had typed alone. setPromptSuggestion(null); + if (explicitText === null) { + const draftUnchanged = inputRef.current?.value === input; + setInput(current => current === input ? '' : current); + if (!wasStreaming && draftUnchanged) setInputExpanded(false); + } setPendingImages(current => current.filter(image => !imgs.includes(image))); - if (!wasStreaming && draftUnchanged) setInputExpanded(false); streamRef.current?.nudge(); if (hostQueue?.getSnapshot().snapshot?.receipt?.status === 'queued') { setInfoToast(t('chat.messageQueued')); @@ -1321,10 +1329,11 @@ const ChatPage: React.FC = ({ onCompositionStart={handleCompositionStart} onKeyDown={handleKeyDown} onRemoveImage={removeImage} - onSend={handleSend} + onSend={() => void handleSend()} pendingImages={pendingImages} promptSuggestion={promptSuggestion} onAcceptPromptSuggestion={acceptPromptSuggestion} + onSendPromptSuggestion={suggestion => void handleSend(suggestion)} streaming={isStreaming} /> diff --git a/src/mobile-web/src/styles/components/chat-input.scss b/src/mobile-web/src/styles/components/chat-input.scss index b71721c89e..e3ae07daef 100644 --- a/src/mobile-web/src/styles/components/chat-input.scss +++ b/src/mobile-web/src/styles/components/chat-input.scss @@ -81,6 +81,9 @@ .chat-page__prompt-suggestion { align-self: flex-start; + display: flex; + align-items: center; + gap: 6px; max-width: 100%; min-width: 0; @@ -95,6 +98,17 @@ } } +// The bubble accepts the suggestion into the editor; the trailing control +// submits it directly so a suggestion can be sent with a single tap. +.chat-page__prompt-suggestion-accept { + flex: 0 1 auto; + min-width: 0; +} + +.chat-page__prompt-suggestion-send { + flex: 0 0 auto; +} + // ── Model and reasoning controls ───────────────────────────────────────── .chat-model-selector { diff --git a/src/mobile-web/tests/fixtures/prompt-suggestion.tsx b/src/mobile-web/tests/fixtures/prompt-suggestion.tsx index fd667e8a08..40c09921fd 100644 --- a/src/mobile-web/tests/fixtures/prompt-suggestion.tsx +++ b/src/mobile-web/tests/fixtures/prompt-suggestion.tsx @@ -55,6 +55,8 @@ export function mountPromptSuggestionFixture() { promptSuggestion={suggestion} onAcceptPromptSuggestion={value => { calls.push(value); setInput(value); setExpanded(true); requestAnimationFrame(() => inputRef.current?.focus()); + }} onSendPromptSuggestion={value => { + calls.push(`send:${value}`); setSuggestion(null); }} />
; } diff --git a/src/mobile-web/tests/prompt-suggestion-browser.test.mjs b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs index 3869b46f5b..5ccbb47083 100644 --- a/src/mobile-web/tests/prompt-suggestion-browser.test.mjs +++ b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs @@ -91,3 +91,42 @@ test('suggestions fit the composer across compact, foldable and wide layouts wit } } finally { await browser.close(); await server.close(); } }); + +test('the trailing control sends a suggestion without touching the draft', { timeout: 60_000 }, async () => { + const server = await startSourceServer(); + const browser = await launchBrowser(); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.origin); + await page.evaluate(async () => { + const { mountPromptSuggestionFixture } = await import('/tests/fixtures/prompt-suggestion.tsx'); + window.suggestions = mountPromptSuggestionFixture(); + }); + await page.waitForFunction(() => window.suggestions.ready()); + await page.evaluate(() => { + const fixture = window.suggestions; + fixture.event({ event: 'session-record', session_id: 'session', payload: { + sessionId: 'session', id: 'turn/first', revision: 1, + turn: { turnId: 'first', sessionId: 'session', turnIndex: 0, status: 'completed', timestamp: 1, userMessage: { id: 'user-1', content: 'Review', timestamp: 1 } }, + } }); + fixture.event({ event: 'agentic://prompt-suggestion', session_id: 'session', + payload: { sessionId: 'session', sourceTurnId: 'first', suggestion: 'Run the tests' } }); + fixture.caughtUp(); + }); + const selector = '[data-testid="chat-prompt-suggestion"]'; + await page.waitForSelector(selector); + // The compact accept affordance must stay the default target; sending is an + // explicit second control, not the whole bubble. + assert.equal(await page.$eval(selector, node => node.textContent), 'Run the tests'); + await page.click('[data-testid="chat-prompt-suggestion-send"]'); + await page.waitForFunction(() => window.suggestions.calls.length === 1); + assert.deepEqual(await page.evaluate(() => window.suggestions.calls), ['send:Run the tests']); + await page.waitForSelector(selector, { hidden: true }); + assert.equal( + await page.evaluate(() => document.querySelector('textarea')?.value ?? ''), + '', + 'a one-tap send must not fill the draft', + ); + } finally { await browser.close(); await server.close(); } +}); From 80ce882d25d71d59b2379a23b5f28e2448baaee4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 2 Nov 2026 12:24:35 +0800 Subject: [PATCH 2/3] test(mobile): cover suggestion sends through the real chat queue Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../tests/fixtures/assistant-session.tsx | 23 +++++-- .../tests/prompt-suggestion-browser.test.mjs | 65 ++++++++++++++++++- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/mobile-web/tests/fixtures/assistant-session.tsx b/src/mobile-web/tests/fixtures/assistant-session.tsx index 54b68775cb..925b1b88e8 100644 --- a/src/mobile-web/tests/fixtures/assistant-session.tsx +++ b/src/mobile-web/tests/fixtures/assistant-session.tsx @@ -15,7 +15,9 @@ import type { RelayHttpClient } from '../../src/services/RelayHttpClient'; import { queueStorage, type QueueItem, type QueueOutboxRecord } from '../../../shared/dialog-queue/HostDialogQueue'; /** Real sidebar, chat, manager and durable outbox; only host IO is simulated. */ -export async function mountAssistantSessionFixture({ legacyWorkspace = false, queueSupported = true, legacyPending = false, selectedProject = false } = {}) { +export async function mountAssistantSessionFixture({ legacyWorkspace = false, queueSupported = true, legacyPending = false, selectedProject = false, openChat = false, holdSubmissions = false } = {}) { + let streamHandlers: Parameters[1] | undefined; + let settleSubmission: ((error?: string) => void) | undefined; const account = 'assistant-mode-account', device = 'assistant-mode-host', sessionId = 'assistant-session'; const capabilities = ['host_stream_v1', ...(queueSupported ? ['dialog_queue_v1'] : []), ...(legacyWorkspace ? [] : ['workspace_id_references_v1', 'harness_profiles_v1'])]; @@ -32,7 +34,7 @@ export async function mountAssistantSessionFixture({ legacyWorkspace = false, qu const projectSessions: SessionInfo[] = projects.map((workspace) => ({ session_id: `${workspace.name}-session`, name: `${workspace.name} session`, agent_type: 'agentic', workspace_id: workspace.workspace_id, workspace_path: workspace.path, workspace_name: workspace.name, created_at: '1', updated_at: '1', message_count: 0 })); - const calls: Array> = [], sessions: SessionInfo[] = legacyPending ? [session] : []; + const calls: Array> = [], sessions: SessionInfo[] = legacyPending || openChat ? [session] : []; const receipts = new Map(); const scope = JSON.stringify([account, device, sessionId]); const pending: QueueOutboxRecord = { scope, key: JSON.stringify([scope, 'before-upgrade-turn']), @@ -48,6 +50,7 @@ export async function mountAssistantSessionFixture({ legacyWorkspace = false, qu isControlTargetCurrent: (target: { deviceId: string; epoch: number }) => target.deviceId === device && target.epoch === 0, onControlTargetChange: () => () => {}, onDeviceDirectoryChanged: () => () => {}, subscribeHostStream: async (_id: string, handlers: Parameters[1]) => { + streamHandlers = handlers; handlers.onHistoryState?.({ hasMore: false, oldestSeq: null, cursor: 0, truncated: false }); handlers.onCaughtUp?.(); return { close() {}, wake() {}, loadOlder: async () => {} }; @@ -78,6 +81,9 @@ export async function mountAssistantSessionFixture({ legacyWorkspace = false, qu const request = command.request; let receipt = request.action === 'get' ? receipts.get(request.turnId) ?? null : null; if (request.action === 'submit') { + if (holdSubmissions) await new Promise((resolve, reject) => { + settleSubmission = error => error ? reject(new Error(error)) : resolve(); + }); const message = request.message; if (!['Claw', 'Cowork', 'agentic', 'minimal', 'Ultra', 'Creative', 'plugin::claw'].includes(message.agentType)) { throw new Error(`Unknown session mode: ${message.agentType}`); @@ -105,7 +111,7 @@ export async function mountAssistantSessionFixture({ legacyWorkspace = false, qu document.body.replaceChildren(element); const root = createRoot(element); function Fixture() { - const [selected, setSelected] = useState<{ id: string; agentType?: string; workspace?: SessionWorkspaceContext } | null>(legacyPending ? { id: sessionId, agentType: 'Claw' } : null); + const [selected, setSelected] = useState<{ id: string; agentType?: string; workspace?: SessionWorkspaceContext } | null>(legacyPending || openChat ? { id: sessionId, agentType: 'Claw' } : null); return {selected ? ; } root.render(); - return { calls, manager, pending, receipts, sessionId, dispose: () => root.unmount() }; + return { calls, manager, pending, receipts, sessionId, dispose: () => root.unmount(), + ready: () => !!streamHandlers, + event: (event: Parameters['onEvent']>[0]) => streamHandlers?.onEvent(event), + completeSubmission(error?: string) { + if (!settleSubmission) throw new Error('No submission is waiting for acknowledgement'); + const settle = settleSubmission; + settleSubmission = undefined; + settle(error); + }, + }; } diff --git a/src/mobile-web/tests/prompt-suggestion-browser.test.mjs b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs index 5ccbb47083..d366992dda 100644 --- a/src/mobile-web/tests/prompt-suggestion-browser.test.mjs +++ b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs @@ -10,14 +10,17 @@ async function assertSuggestionFits(page, selector) { const label = node.querySelector('[data-openbitfun-part="label"]').getBoundingClientRect(); const icon = node.querySelector('[data-openbitfun-part="trailing"]').getBoundingClientRect(); const composer = document.querySelector('.chat-page__composer').getBoundingClientRect(); + const send = document.querySelector('[data-testid="chat-prompt-suggestion-send"]').getBoundingClientRect(); return { - button: button.toJSON(), label: label.toJSON(), icon: icon.toJSON(), composer: composer.toJSON(), + button: button.toJSON(), label: label.toJSON(), icon: icon.toJSON(), composer: composer.toJSON(), send: send.toJSON(), scrollWidth: node.scrollWidth, clientWidth: node.clientWidth, }; }); assert.ok(bounds.label.width > 0, 'the suggestion label needs its own constrained layout box'); assert.ok(bounds.button.left >= bounds.composer.left - 1); assert.ok(bounds.button.right <= bounds.composer.right + 1); + assert.ok(bounds.send.width > 0 && bounds.send.left >= bounds.button.right, 'send must stay separate from accept'); + assert.ok(bounds.send.right <= bounds.composer.right + 1, 'the send control must also fit the composer'); assert.ok(bounds.label.left >= bounds.button.left); assert.ok(bounds.label.right <= bounds.icon.left); assert.ok(bounds.icon.right < bounds.button.right, 'the acceptance icon must remain inside the button'); @@ -130,3 +133,63 @@ test('the trailing control sends a suggestion without touching the draft', { tim ); } finally { await browser.close(); await server.close(); } }); + +test('one-tap suggestions use the real chat queue and preserve drafts across acknowledgements and errors', { timeout: 60_000 }, async () => { + const server = await startSourceServer(); + const browser = await launchBrowser(); + try { + for (const fails of [false, true]) { + const page = await browser.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.origin); + await page.evaluate(async () => { + const { mountAssistantSessionFixture } = await import('/tests/fixtures/assistant-session.tsx'); + window.chat = await mountAssistantSessionFixture({ openChat: true, holdSubmissions: true }); + }); + await page.waitForFunction(() => window.chat.ready()); + await page.evaluate(() => { + const { sessionId } = window.chat; + window.chat.event({ event: 'session-record', session_id: sessionId, payload: { + sessionId, id: 'turn/first', revision: 1, + turn: { turnId: 'first', sessionId, turnIndex: 0, status: 'completed', timestamp: 1, + userMessage: { id: 'user-1', content: 'Review', timestamp: 1 } }, + } }); + window.chat.event({ event: 'agentic://prompt-suggestion', session_id: sessionId, + payload: { sessionId, sourceTurnId: 'first', suggestion: 'Run the tests' } }); + }); + const send = '[data-testid="chat-prompt-suggestion-send"]'; + await page.waitForSelector(send); + assert.equal(await page.evaluate(() => window.chat.calls.filter(call => call.request?.action === 'submit').length), 0); + await page.click(send); + await page.waitForFunction(() => window.chat.calls.some(call => call.request?.action === 'submit')); + const submissions = await page.evaluate(() => window.chat.calls.filter(call => call.request?.action === 'submit')); + assert.equal(submissions.length, 1); + assert.equal(submissions[0].request.message.content, 'Run the tests'); + assert.equal(submissions[0].request.message.agentType, 'Claw'); + assert.equal(submissions[0].device, 'assistant-mode-host'); + assert.equal(await page.$eval('textarea', node => node.value), ''); + await page.waitForSelector(send, { hidden: true }); + if (!fails) await page.type('textarea', 'Keep this newer draft'); + await page.evaluate(fails => window.chat.completeSubmission(fails ? 'Suggestion submission failed' : undefined), fails); + if (fails) { + await page.waitForFunction(() => document.body.textContent.includes('Suggestion submission failed')); + await page.waitForSelector(send); + assert.equal(await page.evaluate(() => window.chat.receipts.size), 0); + assert.equal(await page.$eval('textarea', node => node.value), ''); + } else { + await page.waitForFunction(() => window.chat.receipts.size === 1); + assert.equal(await page.$eval('textarea', node => node.value), 'Keep this newer draft'); + // Clear the newer draft: an acknowledged suggestion must not return. + await page.$eval('textarea', node => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(node, ''); + node.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + assert.equal(await page.$(send), null); + } + assert.equal(await page.evaluate(() => window.chat.calls.filter(call => call.request?.action === 'submit').length), 1); + await page.evaluate(() => window.chat.dispose()); + await page.close(); + } + } finally { await browser.close(); await server.close(); } +}); From 8cbda7ac2c150ac98d35f0ad029017613b7cb30c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 2 Nov 2026 12:46:14 +0800 Subject: [PATCH 3/3] fix(mobile-web): keep draft images out of one-tap suggestion sends --- src/mobile-web/src/pages/ChatPage.tsx | 3 +- .../tests/prompt-suggestion-browser.test.mjs | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 5f000fd9e7..fda8508d82 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -998,7 +998,8 @@ const ChatPage: React.FC = ({ // sent in one tap. const explicitText = typeof overrideText === 'string' ? overrideText : null; const text = (explicitText ?? input).trim(); - const imgs = pendingImages; + // Attachments belong to the draft, which a suggestion leaves untouched. + const imgs = explicitText === null ? pendingImages : []; if ((!text && imgs.length === 0) || imageAnalyzing || sendInFlightRef.current) return; const targetEpoch = captureChatTargetEpoch(); if (targetEpoch === null) return; diff --git a/src/mobile-web/tests/prompt-suggestion-browser.test.mjs b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs index d366992dda..ce960f4fe2 100644 --- a/src/mobile-web/tests/prompt-suggestion-browser.test.mjs +++ b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs @@ -1,4 +1,7 @@ import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; import { launchBrowser, startSourceServer } from './helpers/browser-account-harness.mjs'; @@ -193,3 +196,43 @@ test('one-tap suggestions use the real chat queue and preserve drafts across ack } } finally { await browser.close(); await server.close(); } }); + +test('a one-tap suggestion leaves images attached to the draft', { timeout: 60_000 }, async () => { + const server = await startSourceServer(); + const browser = await launchBrowser(); + const directory = await mkdtemp(join(tmpdir(), 'suggestion-image-')); + try { + const image = join(directory, 'draft.png'); + await writeFile(image, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEBgIApD5fRAAAAABJRU5ErkJggg==', 'base64')); + const page = await browser.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.origin); + await page.evaluate(async () => { + const { mountAssistantSessionFixture } = await import('/tests/fixtures/assistant-session.tsx'); + window.chat = await mountAssistantSessionFixture({ openChat: true }); + }); + await page.waitForFunction(() => window.chat.ready()); + await page.evaluate(() => { + const { sessionId } = window.chat; + window.chat.event({ event: 'session-record', session_id: sessionId, payload: { + sessionId, id: 'turn/first', revision: 1, + turn: { turnId: 'first', sessionId, turnIndex: 0, status: 'completed', timestamp: 1, + userMessage: { id: 'user-1', content: 'Review', timestamp: 1 } }, + } }); + window.chat.event({ event: 'agentic://prompt-suggestion', session_id: sessionId, + payload: { sessionId, sourceTurnId: 'first', suggestion: 'Run the tests' } }); + }); + const input = await page.waitForSelector('input[type="file"]'); + await input.uploadFile(image); + await page.waitForSelector('.chat-page__image-preview-row'); + const send = '[data-testid="chat-prompt-suggestion-send"]'; + await page.waitForSelector(send); + await page.click(send); + await page.waitForFunction(() => window.chat.receipts.size === 1); + const [submission] = await page.evaluate(() => window.chat.calls.filter(call => call.request?.action === 'submit')); + assert.equal(submission.request.message.content, 'Run the tests'); + assert.deepEqual(submission.request.message.attachments ?? [], []); + assert.ok(await page.$('.chat-page__image-preview-row'), 'the draft image must stay attached'); + await page.evaluate(() => window.chat.dispose()); + } finally { await browser.close(); await server.close(); await rm(directory, { recursive: true, force: true }); } +});