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={}
- >
- {visibleSuggestion}
-
+
+ onAcceptPromptSuggestion?.(visibleSuggestion)}
+ size="sm"
+ trailing={}
+ >
+ {visibleSuggestion}
+
+ }
+ onClick={() => (onSendPromptSuggestion ?? onAcceptPromptSuggestion)?.(visibleSuggestion)}
+ size="sm"
+ />
+
) : 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 1ac64e5c78..fda8508d82 100644
--- a/src/mobile-web/src/pages/ChatPage.tsx
+++ b/src/mobile-web/src/pages/ChatPage.tsx
@@ -991,10 +991,15 @@ 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();
- const imgs = pendingImages;
+ // `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();
+ // 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;
@@ -1037,11 +1042,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'));
@@ -1338,10 +1347,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/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/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..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';
@@ -10,14 +13,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');
@@ -91,3 +97,142 @@ 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(); }
+});
+
+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(); }
+});
+
+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 }); }
+});