Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/mobile-web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/mobile-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/mobile-web/src/styles/components/chat-input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/mobile-web/src/styles/components/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions src/mobile-web/tests/fixtures/chat-rollback.tsx
Original file line number Diff line number Diff line change
@@ -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<RemoteSessionManager['subscribeSessionStream']>[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 <ThemeProvider><I18nProvider>
<div className="mobile-app" data-layout={wide ? 'wide' : 'compact'}>
<div className="remote-shell">
{wide && <aside className="remote-shell__master" />}
<section className="remote-shell__detail">
<ChatPage sessionMgr={manager} sessionId="session" sessionName="Layout fixture" onBack={() => {}} wideLayout={wide} />
</section>
</div>
</div>
</I18nProvider></ThemeProvider>;
}
const root = createRoot(element);
root.render(<Fixture />);
return { calls, dispose: () => root.unmount() };
}
5 changes: 4 additions & 1 deletion src/mobile-web/tests/fixtures/prompt-suggestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteSessionManager['subscribeSessionStream']>[1];
let handlers: StreamHandlers;
Expand All @@ -25,6 +27,7 @@ export function mountPromptSuggestionFixture() {
} } as unknown as RemoteSessionManager;
const root = createRoot(element);
function Fixture() {
const wide = useWideLayout();
const inputRef = useRef<HTMLTextAreaElement>(null);
const [input, setInput] = useState('');
const [expanded, setExpanded] = useState(false);
Expand All @@ -43,7 +46,7 @@ export function mountPromptSuggestionFixture() {
return () => stream.stop();
}, []);
const noop = () => {};
return <ThemeProvider><I18nProvider><div className="chat-page">
return <ThemeProvider><I18nProvider><div className={`chat-page${wide ? ' chat-page--wide' : ''}`}>
<ChatComposerBar cancelling={false} containerRef={null} expanded={expanded}
imageAnalyzing={false} sending={sending} input={input} inputRef={inputRef} modelControls={null}
onActivate={() => setExpanded(true)} onAttach={noop} onCancel={noop} onChange={setInput}
Expand Down
120 changes: 78 additions & 42 deletions src/mobile-web/tests/prompt-suggestion-browser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
});
Loading
Loading