From 713897bb259cc0895c20321338bcc7f52842686f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 19 Oct 2026 11:28:35 +0800 Subject: [PATCH] fix(mobile-web): contain suggestions and widen message sheets --- .github/workflows/ci.yml | 19 +++ src/mobile-web/AGENTS.md | 1 + src/mobile-web/package.json | 1 + .../src/styles/components/chat-input.scss | 3 + .../src/styles/components/chat.scss | 5 +- .../tests/fixtures/chat-rollback.tsx | 61 +++++++++ .../tests/fixtures/prompt-suggestion.tsx | 5 +- .../tests/prompt-suggestion-browser.test.mjs | 120 ++++++++++++------ .../tests/rollback-browser.test.mjs | 92 ++++++++++++++ 9 files changed, 263 insertions(+), 44 deletions(-) create mode 100644 src/mobile-web/tests/fixtures/chat-rollback.tsx create mode 100644 src/mobile-web/tests/rollback-browser.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98ba8ba715..a664cf5c79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -693,6 +693,25 @@ jobs: timeout-minutes: 5 run: pnpm --dir src/mobile-web run test:account-browser + - name: Test mobile web composer and rollback layouts + if: needs.build-impact.outputs.frontend_required != 'false' + timeout-minutes: 3 + run: | + pnpm --dir src/mobile-web run test:prompt-suggestion-browser + pnpm --dir src/mobile-web run test:rollback-browser + + - name: Upload mobile input layout screenshots + if: always() && needs.build-impact.outputs.frontend_required != 'false' + uses: actions/upload-artifact@v6 + with: + name: mobile-input-layout-screenshots + path: | + /tmp/mobile-prompt-suggestion-*.png + /tmp/mobile-edit-*.png + /tmp/mobile-rollback-*.png + if-no-files-found: ignore + retention-days: 7 + - name: Build mobile web if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run build:mobile-web diff --git a/src/mobile-web/AGENTS.md b/src/mobile-web/AGENTS.md index 74796865c8..eebab366f5 100644 --- a/src/mobile-web/AGENTS.md +++ b/src/mobile-web/AGENTS.md @@ -58,6 +58,7 @@ node --test src/mobile-web/tests/background-delivery-browser.test.mjs # quiet ex pnpm --dir src/mobile-web run test:host-queue # idempotent outbox and real browser close/reopen pnpm --dir src/mobile-web run test:host-stream # HostStream reader: hints, reconnects, host restart, unsupported hosts; catalog subscription; next-input suggestions pnpm --dir src/mobile-web run test:prompt-suggestion-browser # Real composer and stream projection: compact/wide, original text, offline/sending gates, no automatic submission +pnpm --dir src/mobile-web run test:rollback-browser # Real chat edit/rollback sheets: full-width fields, long text, live resize, reduced viewport, cancel without host mutations pnpm --dir src/mobile-web run test:account-login # account login without an online desktop pnpm --dir src/mobile-web run test:account-browser # real Chrome tabs, persistence, migration, races; simulated Relay pnpm --dir src/mobile-web run test:images # image preparation and upload limits diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index c7b76419a5..df4c43cf9a 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -22,6 +22,7 @@ "test:host-queue": "node --test tests/host-dialog-queue.test.mjs tests/host-dialog-queue-browser.test.mjs", "test:host-stream": "node --test tests/host-stream.test.mjs tests/host-catalog-subscription.test.mjs tests/prompt-suggestion.test.mjs", "test:prompt-suggestion-browser": "node --test tests/prompt-suggestion-browser.test.mjs", + "test:rollback-browser": "node --test tests/rollback-browser.test.mjs", "test:session-stream-browser": "node --test tests/session-stream-browser.test.mjs", "test:runtime-files": "node --test tests/runtime-file-upload.test.mjs tests/runtime-file-download.test.mjs", "test:interaction-mailbox": "node --test tests/interaction-mailbox-browser.test.mjs", diff --git a/src/mobile-web/src/styles/components/chat-input.scss b/src/mobile-web/src/styles/components/chat-input.scss index 8771d9d0ac..b71721c89e 100644 --- a/src/mobile-web/src/styles/components/chat-input.scss +++ b/src/mobile-web/src/styles/components/chat-input.scss @@ -85,6 +85,9 @@ min-width: 0; [data-openbitfun-part="label"] { + // MobileButton uses display: contents by default; ellipsis needs a box. + display: block; + flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; diff --git a/src/mobile-web/src/styles/components/chat.scss b/src/mobile-web/src/styles/components/chat.scss index c9e3996a0d..a05010091e 100644 --- a/src/mobile-web/src/styles/components/chat.scss +++ b/src/mobile-web/src/styles/components/chat.scss @@ -383,9 +383,12 @@ // ── Rollback / edit confirmation ────────────────────────────────── .chat-msg__rollback { display: flex; + // Confirm sheets center their children; message fields need the full body width. + width: 100%; + min-width: 0; flex-direction: column; gap: var(--openbitfun-space-2); - padding: 0 4px; + text-align: start; } .chat-msg__rollback-quote { diff --git a/src/mobile-web/tests/fixtures/chat-rollback.tsx b/src/mobile-web/tests/fixtures/chat-rollback.tsx new file mode 100644 index 0000000000..508e01d44b --- /dev/null +++ b/src/mobile-web/tests/fixtures/chat-rollback.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import '../../src/styles/reset.scss'; +import '@openbitfun/theme-openbitfun/default.css'; +import '@openbitfun/ui/mobile.css'; +import '../../src/styles/index.scss'; +import ChatPage from '../../src/pages/ChatPage'; +import { useWideLayout } from '../../src/hooks/useWideLayout'; +import { I18nProvider } from '../../src/i18n'; +import { ThemeProvider } from '../../src/theme'; +import { useMobileStore } from '../../src/services/store'; +import type { RemoteSessionManager } from '../../src/services/RemoteSessionManager'; + +/** Real chat, message actions and sheets; host IO is isolated from live sessions. */ +export function mountRollbackFixture(text: string) { + const element = document.createElement('main'); + document.body.replaceChildren(element); + useMobileStore.setState({ + connectionHealth: 'connected', currentWorkspace: null, authenticatedUserId: null, + controlTarget: null, activeTurn: null, messagesBySession: {}, error: null, + }); + const calls: string[] = []; + const manager = { + controlTargetEpoch: 0, + onControlTargetChange: () => () => {}, + supportsHostCapability: (name: string) => name === 'session_rollback_v1', + getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }), + invokeHost: async (command: string) => { + if (command !== 'get_session_interaction_mailbox') throw new Error(`Unexpected command: ${command}`); + return { sessionId: 'session', permissions: { revision: 0, requests: [] }, userQuestions: { revision: 0, questions: [] } }; + }, + subscribeSessionStream: async (_id: string, handlers: Parameters[1]) => { + handlers.onEvent({ event: 'session-record', session_id: 'session', seq: 1, payload: { + sessionId: 'session', id: 'turn/first', revision: 1, + turn: { turnId: 'first', sessionId: 'session', turnIndex: 0, status: 'completed', timestamp: 1, + userMessage: { id: 'user-1', content: text, timestamp: 1 } }, + } }); + handlers.onHistoryState?.({ hasMore: false, oldestSeq: 1, cursor: 1, truncated: false }); + handlers.onCaughtUp?.(); + return { close() {}, wake() {}, loadOlder: async () => {} }; + }, + rollbackSessionToTurn: async () => { calls.push('rollback'); throw new Error('Unexpected rollback in layout fixture'); }, + sendMessage: async () => { calls.push('send'); throw new Error('Unexpected send in layout fixture'); }, + } as unknown as RemoteSessionManager; + function Fixture() { + const wide = useWideLayout(); + return +
+
+ {wide &&
+
+
; + } + const root = createRoot(element); + root.render(); + return { calls, dispose: () => root.unmount() }; +} diff --git a/src/mobile-web/tests/fixtures/prompt-suggestion.tsx b/src/mobile-web/tests/fixtures/prompt-suggestion.tsx index e808f8ca2d..fd667e8a08 100644 --- a/src/mobile-web/tests/fixtures/prompt-suggestion.tsx +++ b/src/mobile-web/tests/fixtures/prompt-suggestion.tsx @@ -7,12 +7,14 @@ import '../../src/styles/index.scss'; import ChatComposerBar from '../../src/components/ChatComposerBar'; import { I18nProvider } from '../../src/i18n'; import { ThemeProvider } from '../../src/theme'; +import { useWideLayout } from '../../src/hooks/useWideLayout'; import { SessionSynchronizer } from '../../src/services/SessionSynchronizer'; import type { RemoteSessionManager } from '../../src/services/RemoteSessionManager'; /** Production stream projection and composer; only host IO is simulated. */ export function mountPromptSuggestionFixture() { const element = document.createElement('main'); + element.className = 'mobile-app'; document.body.replaceChildren(element); type StreamHandlers = Parameters[1]; let handlers: StreamHandlers; @@ -25,6 +27,7 @@ export function mountPromptSuggestionFixture() { } } as unknown as RemoteSessionManager; const root = createRoot(element); function Fixture() { + const wide = useWideLayout(); const inputRef = useRef(null); const [input, setInput] = useState(''); const [expanded, setExpanded] = useState(false); @@ -43,7 +46,7 @@ export function mountPromptSuggestionFixture() { return () => stream.stop(); }, []); const noop = () => {}; - return
+ return
setExpanded(true)} onAttach={noop} onCancel={noop} onChange={setInput} diff --git a/src/mobile-web/tests/prompt-suggestion-browser.test.mjs b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs index de3f880ecb..3869b46f5b 100644 --- a/src/mobile-web/tests/prompt-suggestion-browser.test.mjs +++ b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs @@ -2,56 +2,92 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { launchBrowser, startSourceServer } from './helpers/browser-account-harness.mjs'; -test('suggestion stream and composer preserve text across compact and wide layouts without submitting', { timeout: 60_000 }, async () => { +async function assertSuggestionFits(page, selector) { + // The page clips horizontal overflow, so body.scrollWidth alone misses + // text and icons spilling outside an otherwise correctly sized button. + const bounds = await page.$eval(selector, node => { + const button = node.getBoundingClientRect(); + 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(); + return { + button: button.toJSON(), label: label.toJSON(), icon: icon.toJSON(), composer: composer.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.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'); + assert.ok(bounds.scrollWidth <= bounds.clientWidth + 1, 'suggestion content must not overflow the button'); + assert.equal(await page.$eval('body', node => node.scrollWidth <= innerWidth), true); +} + +test('suggestions fit the composer across compact, foldable and wide layouts without losing text or submitting', { timeout: 60_000 }, async () => { const server = await startSourceServer(); const browser = await launchBrowser(); try { const page = await browser.newPage(); + await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]); const selector = '[data-testid="chat-prompt-suggestion"]'; - const text = ' Review the tests\nand explain what still needs fixing '; - for (const width of [390, 1200]) { - await page.setViewport({ width, 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(text => { - 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: text } }); - fixture.caughtUp(); - }, text); - await page.waitForSelector(selector); - assert.equal(await page.$eval(selector, node => node.textContent), text); - assert.equal(await page.$eval('body', node => node.scrollWidth <= innerWidth), true); - for (const gate of ['offline', 'sending']) { - await page.evaluate(gate => window.suggestions[gate](true), gate); + const suggestions = [ + 'Continue', + ' Review the tests\nand explain what still needs fixing ', + '你看到的渲染实际是什么样?把异常的几条贴出来,我帮你定位是字符解析、转义处理还是货币、环境变量的启发式规则出了问题。', + ` https://example.com/${'unbroken-path-segment'.repeat(12)} `, + ]; + for (const [index, text] of suggestions.entries()) { + for (const width of [320, 390, 768, 1200]) { + await page.setViewport({ width, 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(text => { + 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: text } }); + fixture.caughtUp(); + }, text); + await page.waitForSelector(selector); + assert.equal(await page.$eval(selector, node => node.textContent), text); + await assertSuggestionFits(page, selector); + for (const gate of ['offline', 'sending']) { + await page.evaluate(gate => window.suggestions[gate](true), gate); + await page.waitForSelector(selector, { hidden: true }); + await page.evaluate(gate => window.suggestions[gate](false), gate); + await page.waitForSelector(selector); + } + await page.screenshot({ path: `/tmp/mobile-prompt-suggestion-${index}-${width}.png` }); + await page.click(selector); + await page.waitForFunction(text => document.activeElement?.value === text, {}, text); + assert.deepEqual(await page.evaluate(() => window.suggestions.calls), [text]); await page.waitForSelector(selector, { hidden: true }); - await page.evaluate(gate => window.suggestions[gate](false), gate); + await page.$eval('textarea', node => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(node, ''); + node.dispatchEvent(new Event('input', { bubbles: true })); + }); await page.waitForSelector(selector); + await assertSuggestionFits(page, selector); + // The expanded composer must retain its bounds during a live compact/wide switch. + await page.setViewport({ width: width < 900 ? 1200 : 390, height: 844 }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await assertSuggestionFits(page, selector); + await page.evaluate(() => window.suggestions.event({ event: 'session-record', session_id: 'session', payload: { + sessionId: 'session', id: 'turn/second', revision: 2, + turn: { turnId: 'second', sessionId: 'session', turnIndex: 1, status: 'inprogress', timestamp: 2, userMessage: { id: 'user-2', content: 'Continue', timestamp: 2 } }, + } })); + await page.waitForSelector(selector, { hidden: true }); + assert.deepEqual(await page.evaluate(() => window.suggestions.calls), [text]); } - await page.screenshot({ path: `/tmp/mobile-prompt-suggestion-${width}.png` }); - await page.click(selector); - await page.waitForFunction(text => document.activeElement?.value === text, {}, text); - assert.deepEqual(await page.evaluate(() => window.suggestions.calls), [text]); - await page.waitForSelector(selector, { hidden: true }); - await page.$eval('textarea', node => { - Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(node, ''); - node.dispatchEvent(new Event('input', { bubbles: true })); - }); - await page.waitForSelector(selector); - await page.evaluate(() => window.suggestions.event({ event: 'session-record', session_id: 'session', payload: { - sessionId: 'session', id: 'turn/second', revision: 2, - turn: { turnId: 'second', sessionId: 'session', turnIndex: 1, status: 'inprogress', timestamp: 2, userMessage: { id: 'user-2', content: 'Continue', timestamp: 2 } }, - } })); - await page.waitForSelector(selector, { hidden: true }); - assert.deepEqual(await page.evaluate(() => window.suggestions.calls), [text]); } } finally { await browser.close(); await server.close(); } }); diff --git a/src/mobile-web/tests/rollback-browser.test.mjs b/src/mobile-web/tests/rollback-browser.test.mjs new file mode 100644 index 0000000000..154a61bc4d --- /dev/null +++ b/src/mobile-web/tests/rollback-browser.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { launchBrowser, startSourceServer } from './helpers/browser-account-harness.mjs'; + +const text = String.raw`行内 $x$、$x^2+y^2=z^2$、\(\alpha\)、\(\frac{a}{b}\) + +$$E=mc^2$$ + +https://example.com/${'unbroken-path-segment'.repeat(16)} + +${'Keep every line of the original message.\n'.repeat(16)}`.trim(); + +async function clickButton(page, label) { + await page.waitForFunction(label => Array.from(document.querySelectorAll('button')).some(node => node.textContent === label), {}, label); + await page.evaluate(label => Array.from(document.querySelectorAll('button')).find(node => node.textContent === label).click(), label); +} + +async function assertSheetLayout(page, selector) { + // Media-query changes and sheet presence can restart the entrance animation. + await page.$eval(selector, async node => { + const sheet = node.closest('[role="dialog"]'); + await Promise.all(sheet.getAnimations({ subtree: true }).map(animation => animation.finished.catch(() => {}))); + }); + const layout = await page.$eval(selector, node => { + const sheet = node.closest('[role="dialog"]'); + const body = sheet.querySelector('[data-openbitfun-part="body"]'); + const actions = sheet.querySelector('[data-openbitfun-part="actions"]'); + const field = node.closest('[data-openbitfun-component="mobile-textarea"]') ?? node; + return { + sheet: sheet.getBoundingClientRect().toJSON(), body: body.getBoundingClientRect().toJSON(), + field: field.getBoundingClientRect().toJSON(), actions: actions.getBoundingClientRect().toJSON(), + textAlign: getComputedStyle(node).textAlign, + scrollWidth: node.scrollWidth, clientWidth: node.clientWidth, + scrollHeight: node.scrollHeight, clientHeight: node.clientHeight, + viewport: { width: innerWidth, height: innerHeight }, + }; + }); + assert.ok(Math.abs(layout.field.left - layout.actions.left) <= 1, 'message field must align with the actions, not shrink to its text'); + assert.ok(Math.abs(layout.field.right - layout.actions.right) <= 1, 'message field must use the available sheet width'); + assert.ok(['left', 'start'].includes(layout.textAlign), 'message content must be leading-aligned'); + assert.ok(layout.scrollWidth <= layout.clientWidth + 1, 'unbroken text must not cause horizontal overflow'); + assert.ok(layout.scrollHeight > layout.clientHeight, 'long content must remain scrollable'); + assert.ok(layout.sheet.left >= 0 && layout.sheet.right <= layout.viewport.width); + assert.ok(layout.sheet.top >= 0 && layout.sheet.bottom <= layout.viewport.height + 1, JSON.stringify(layout)); + assert.ok(layout.body.bottom <= layout.actions.top + 1, 'scrolling content must not overlap the actions'); +} + +test('edit and rollback sheets use the full message width through resize and keep long text scrollable', { timeout: 60_000 }, async () => { + const server = await startSourceServer(); + const browser = await launchBrowser(); + try { + const page = await browser.newPage(); + await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]); + for (const theme of ['dark', 'light']) { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.origin); + await page.evaluate(async ({ text, theme }) => { + document.documentElement.setAttribute('data-color-scheme', theme); + localStorage.setItem('openbitfun-mobile-language', 'en-US'); + window.rollbackFixture = (await import('/tests/fixtures/chat-rollback.tsx')).mountRollbackFixture(text); + }, { text, theme }); + await page.waitForSelector('.chat-msg--user'); + for (const mode of ['edit', 'rollback']) { + await page.$eval('.chat-msg--user', node => node.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }))); + await clickButton(page, mode === 'edit' ? 'Edit & resend' : 'Roll back to here'); + const selector = mode === 'edit' ? '.chat-msg__rollback-input' : '.chat-msg__rollback-quote'; + await page.waitForSelector(selector); + await page.waitForFunction(() => !document.querySelector('[data-openbitfun-component="mobile-sheet"][data-state="exiting"]')); + assert.equal(await page.$eval(selector, node => node.value ?? node.textContent), text); + // Resize the open sheet without remounting it, including a keyboard-sized viewport. + for (const viewport of [{ width: 320, height: 844 }, { width: 390, height: 844 }, { width: 768, height: 844 }, { width: 1200, height: 844 }, { width: 390, height: 420 }]) { + await page.setViewport(viewport); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await assertSheetLayout(page, selector); + await page.screenshot({ path: `/tmp/mobile-${mode}-${theme}-${viewport.width}-${viewport.height}.png` }); + } + await page.$eval(selector, node => { node.scrollTop = node.scrollHeight; }); + assert.ok(await page.$eval(selector, node => node.scrollTop > 0), 'the end of the message is reachable'); + if (mode === 'edit') { + await page.focus(selector); + const before = await page.$eval(selector, node => node.value); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + assert.equal(await page.$eval(selector, node => node.value.length), before.length + 1, 'Enter remains a text-editing action'); + } + await clickButton(page, 'Cancel'); + await page.waitForSelector(selector, { hidden: true }); + assert.deepEqual(await page.evaluate(() => window.rollbackFixture.calls), [], 'layout, editing and cancel must not roll back or send'); + } + } + } finally { await browser.close(); await server.close(); } +});