diff --git a/src/mobile-web/src/components/ChatComposerBar.tsx b/src/mobile-web/src/components/ChatComposerBar.tsx index 13912b0fcf..7f1c322cb0 100644 --- a/src/mobile-web/src/components/ChatComposerBar.tsx +++ b/src/mobile-web/src/components/ChatComposerBar.tsx @@ -21,6 +21,7 @@ interface ChatComposerBarProps { input: string; inputRef: React.Ref; modelControls: React.ReactNode; + queueContent?: React.ReactNode; onActivate: () => void; onAttach: () => void; onCancel: () => void; @@ -44,6 +45,7 @@ export default function ChatComposerBar({ input, inputRef, modelControls, + queueContent, onActivate, onAttach, onCancel, @@ -62,6 +64,7 @@ export default function ChatComposerBar({ return (
+ {queueContent} void }) { const { t } = useI18n(); const view = useSyncExternalStore(queue.subscribe, queue.getSnapshot, queue.getSnapshot); + const [expanded, setExpanded] = useState(true); + const [showHelp, setShowHelp] = useState(false); + const listId = useId(); + const helpId = useId(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => observeHostQueue(queue), [queue]); @@ -15,34 +20,48 @@ export function MobileHostQueue({ queue, onRestore }: { queue: HostDialogQueue; try { await action(); } catch (e) { setError(String(e)); } finally { setBusy(false); } }; + const count = (view.snapshot?.items.length ?? 0) + view.pending.length; if (!view.snapshot?.items.length && !view.pending.length && !view.error && !error) return null; return
- {t('queue.title')} -

{t('queue.memoryNotice')}

- {(error || view.error) && {error || view.error}} - {(error || view.error) && void run(() => queue.refresh())}>{t('queue.refresh')}} -
    - {view.snapshot?.items.map(item =>
  • -

    {item.displayContent}

    - {item.status === 'steering_pending' ? t('queue.steeringPending') : item.status === 'blocked' ? t('queue.blocked') : t('queue.queued')} - {item.attachmentCount > 0 && {t('queue.attachments', { count: item.attachmentCount })}} - {item.reason &&

    {item.reason}

    } -
    - void run(() => queue.act(item, 'promote'))}>{t('queue.sendNow')} - void run(() => queue.act(item, 'cancel'))}>{t('queue.cancel')} -
    -
  • )} - {view.pending.map(record =>
  • -

    {t('queue.unknown')}

    - {record.request.action === 'submit' &&

    {record.request.message.displayContent ?? record.request.message.content}

    } -
    - void run(() => queue.retry(record))}>{t('queue.checkRetry')} - {record.request.action === 'submit' && { - if (record.request.action === 'submit') onRestore(record.request.message.content); - }}>{t('queue.copyDraft')}} - void run(() => queue.dismiss(record))}>{t('queue.dismiss')} -
    -
  • )} -
+
+ setExpanded(value => !value)}> + {t('queue.title')} + {t('common.itemCount', { count })} + {expanded ? +
+
+ {showHelp &&

{t('queue.memoryNotice')}

} + {(error || view.error) && {error || view.error}} + {(error || view.error) && void run(() => queue.refresh())}>{t('queue.refresh')}} + +
; } diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 33d78bbc52..7f68b5dbb7 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -9,7 +9,8 @@ type MessageTree = { readonly [key: string]: MessageLeaf | MessageTree }; export const messages: Record = { 'en-US': { queue: { - "title": "Host message queue", + "title": "Queued messages", + "about": "About queued messages", "memoryNotice": "Accepted messages run while this page is closed. Restarting the execution device clears pending messages.", "queued": "Queued", "blocked": "Waiting for recovery", @@ -373,7 +374,8 @@ export const messages: Record = { }, 'zh-CN': { queue: { - "title": "宿主消息队列", + "title": "待发送", + "about": "排队消息说明", "memoryNotice": "消息接受后,关闭此页面仍会执行。执行设备重启会清空待执行消息。", "queued": "排队中", "blocked": "等待恢复", @@ -737,7 +739,8 @@ export const messages: Record = { }, 'zh-TW': { queue: { - "title": "主機訊息佇列", + "title": "待傳送", + "about": "排隊訊息說明", "memoryNotice": "訊息接受後,關閉此頁面仍會執行。執行裝置重新啟動會清空待執行訊息。", "queued": "排隊中", "blocked": "等待恢復", diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 9eccbab4b5..85da746b76 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -1274,9 +1274,9 @@ const ChatPage: React.FC = ({ style={{ display: 'none' }} onChange={handleFileChange} /> - {hostQueue && { setInput(current => current ? `${current}\n\n${content}` : content); setInputExpanded(true); }} />} { setInput(current => current ? `${current}\n\n${content}` : content); setInputExpanded(true); }} />} cancelling={isCancelling} containerRef={inputBarRef} expanded={inputExpanded} diff --git a/src/mobile-web/src/styles/components/chat-input.scss b/src/mobile-web/src/styles/components/chat-input.scss index ebe036b99f..8a4caacae3 100644 --- a/src/mobile-web/src/styles/components/chat-input.scss +++ b/src/mobile-web/src/styles/components/chat-input.scss @@ -9,6 +9,8 @@ max-width: 520px; z-index: 20; display: flex; + flex-direction: column; + gap: 8px; justify-content: center; padding: 0 16px; transition: max-width 0.3s cubic-bezier(0.4, 0, 0.2, 1), diff --git a/src/mobile-web/src/styles/host-queue.scss b/src/mobile-web/src/styles/host-queue.scss index db0dda3f45..edc6fcd419 100644 --- a/src/mobile-web/src/styles/host-queue.scss +++ b/src/mobile-web/src/styles/host-queue.scss @@ -1,12 +1,62 @@ .host-message-queue { - padding: 8px 12px; - max-height: 28vh; - overflow: auto; + width: 100%; + min-width: 0; + overflow: hidden; flex-shrink: 0; - font-size: var(--openbitfun-type-body-xs-font-size); - ul { list-style: none; margin: 0; padding: 0; } - li { padding-block: 8px; } - p { margin-block: 4px; } - &__preview { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 5em; overflow: auto; } - &__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } + border: 1px solid var(--openbitfun-color-border-subtle); + border-radius: 20px; + background: var(--openbitfun-color-surface-panel); + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + + &__header { display: flex; align-items: center; padding: 0 8px 0 12px; } + &__toggle { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + min-height: 40px; + height: 40px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: start; + font: inherit; + box-shadow: none; + justify-content: flex-start; + [data-openbitfun-part="label"] { display: flex; align-items: center; gap: 8px; } + cursor: pointer; + &:focus-visible { outline: 2px solid var(--openbitfun-color-focus-ring); outline-offset: -2px; border-radius: 8px; } + } + &__icon { + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + padding: 0; + flex-shrink: 0; + color: var(--openbitfun-color-content-secondary); + } + &__row { display: flex; align-items: center; gap: 4px; } + &__message { flex: 1; min-width: 0; } + &__row &__actions { flex-wrap: nowrap; gap: 0; margin: 0; } + &__count, &__status, &__help { color: var(--openbitfun-color-content-secondary); } + &__help { margin: 0; padding: 0 12px 12px; } + &__body { + max-height: min(240px, calc(var(--mobile-viewport-height) * 0.28)); + overflow-y: auto; + overscroll-behavior: contain; + } + &__list { + list-style: none; + margin: 0; + padding: 0 12px; + li { padding: 4px 0; border-top: 1px solid var(--openbitfun-color-border-subtle); } + p { margin: 0 0 4px; } + } + &__preview { white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + &__actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 4px; } } diff --git a/src/mobile-web/tests/fixtures/host-queue.html b/src/mobile-web/tests/fixtures/host-queue.html new file mode 100644 index 0000000000..90fd21a8de --- /dev/null +++ b/src/mobile-web/tests/fixtures/host-queue.html @@ -0,0 +1,9 @@ + +Mobile queue layout + diff --git a/src/mobile-web/tests/fixtures/host-queue.tsx b/src/mobile-web/tests/fixtures/host-queue.tsx index c068567434..2d0f0a2aaf 100644 --- a/src/mobile-web/tests/fixtures/host-queue.tsx +++ b/src/mobile-web/tests/fixtures/host-queue.tsx @@ -1,28 +1,50 @@ -import React from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; +import '../../src/styles/reset.scss'; +import '@openbitfun/theme-openbitfun/default.css'; +import '@openbitfun/ui/mobile.css'; +import '../../src/styles/index.scss'; import { createRoot } from 'react-dom/client'; import ChatComposerBar from '../../src/components/ChatComposerBar'; import { MobileHostQueue } from '../../src/components/MobileHostQueue'; +import { useMobileViewport } from '../../src/hooks/useMobileViewport'; +import { ThemeProvider } from '../../src/theme'; import { I18nProvider } from '../../src/i18n'; import { HostDialogQueue } from '../../../shared/dialog-queue/HostDialogQueue'; -export function mountHostQueueFixture() { +export function mountHostQueueFixture({ count = 1, expanded = true } = {}) { const element = document.createElement('main'); + element.style.height = '100%'; document.body.replaceChildren(element); const calls: string[] = []; + let items = Array.from({ length: count }, (_, index) => ({ turnId: `queued-${index}`, displayContent: index === 0 ? '接着检查错误处理和测试覆盖' : `排队消息 ${index + 1}:分析当前项目的实现,检查可能的问题。`, previewTruncated: false, + attachmentCount: 0, agentType: 'Standard', createdAtMs: 1, status: 'queued' as const, reason: null, targetTurnId: null, steeringId: null })); const queue = new HostDialogQueue('ui-fixture', 'session', async request => { calls.push(request.action); + if (request.action === 'cancel' || request.action === 'promote') items = items.filter(item => item.turnId !== request.turnId); return { sessionId: 'session', queueEpoch: 'epoch', revision: calls.length, activeTurnId: 'active', - items: [{ turnId: 'queued', content: '', displayContent: '接着检查错误处理和测试覆盖', previewTruncated: false, - attachmentCount: 0, agentType: 'Standard', createdAtMs: 1, status: 'queued', reason: null, targetTurnId: null, steeringId: null }], - capacity: 20, used: 1, receipt: null }; + items, capacity: 20, used: items.length, receipt: null }; }); const noop = () => {}; const root = createRoot(element); - root.render( - (null); + const [height, setHeight] = useState(56); + useLayoutEffect(() => { + const observer = new ResizeObserver(() => setHeight(ref.current!.getBoundingClientRect().height)); + observer.observe(ref.current!); + return () => observer.disconnect(); + }, []); + return
= 900 ? ' chat-page--wide' : ''}`} style={{ '--chat-composer-height': `${height}px` } as React.CSSProperties}> +
项目介绍
+

正在检查代码和测试覆盖。

ExecCommand

分析当前项目的实现。

+ } + cancelling={false} containerRef={ref} expanded={expanded} imageAnalyzing={false} sending={false} input="继续检查" inputRef={null} modelControls={null} onActivate={noop} onAttach={noop} onCancel={() => calls.push('stop')} onChange={noop} onCompositionEnd={noop} onCompositionStart={noop} onKeyDown={noop} onRemoveImage={noop} onSend={() => calls.push('send')} pendingImages={[]} remoteUnavailable={false} streaming /> - ); +
; + } + root.render(); return { calls, dispose: () => root.unmount() }; } diff --git a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs index 7e2599ccdb..e2779a4152 100644 --- a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs +++ b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs @@ -43,6 +43,7 @@ test('mobile running composer keeps send and stop independently available alongs window.queueFixture=mountHostQueueFixture(); }); await page.waitForSelector('.host-message-queue li'); + assert.ok(await page.$eval('.host-message-queue',el=>el.getBoundingClientRect().height<=100),'one queued message stays compact'); const actions=await page.$$eval('.chat-page__send-btn', buttons=>buttons.map(button=>({disabled:button.disabled,stop:button.classList.contains('is-stop')}))); assert.deepEqual(actions,[{disabled:false,stop:true},{disabled:false,stop:false}]); await page.click('.chat-page__send-btn:not(.is-stop)'); @@ -52,3 +53,47 @@ test('mobile running composer keeps send and stop independently available alongs await page.screenshot({path:'/tmp/mobile-host-message-queue.png',fullPage:true}); }finally{await browser.close();await server.close();} }); + +test('queue stays above the measured composer across phone, keyboard-height and wide layouts', {timeout:60000}, async () => { + const server=await startSourceServer();const browser=await launchBrowser(); + try { + const page=await browser.newPage(); + for(const [width,height] of [[320,568],[390,844],[390,420],[768,800],[1200,900]]) { + await page.setViewport({width,height}); + await page.goto(server.origin+'/tests/fixtures/host-queue.html?count=8'); + await page.waitForSelector('.host-message-queue li'); + await page.waitForFunction(()=>{ + const wrap=document.querySelector('.chat-page__input-wrap'); + return Math.abs(parseFloat(getComputedStyle(document.querySelector('.chat-page')).getPropertyValue('--chat-composer-height'))-wrap.getBoundingClientRect().height)<1; + }); + const layout=await page.evaluate(()=>{ + const box=selector=>document.querySelector(selector).getBoundingClientRect(); + const queue=box('.host-message-queue'),composer=box('.chat-page__composer'),wrap=box('.chat-page__input-wrap'); + const body=document.querySelector('.host-message-queue__body'); + return {noOverlap:queue.bottom<=composer.top,inside:queue.top>=0&&composer.bottom<=innerHeight, + width:document.body.scrollWidth<=innerWidth,aligned:Math.abs(queue.left-composer.left)<1&&Math.abs(queue.right-composer.right)<1, + scrollable:body.scrollHeight>body.clientHeight,reserved:parseFloat(getComputedStyle(document.querySelector('.chat-page__messages')).paddingBottom)>=wrap.height}; + }); + assert.deepEqual(layout,{noOverlap:true,inside:true,width:true,aligned:true,scrollable:true,reserved:true},`${width}x${height}`); + const before=await page.$eval('.chat-page__input-wrap',el=>el.getBoundingClientRect().height); + await page.click('.host-message-queue__toggle'); + await page.waitForFunction(()=>document.querySelector('.host-message-queue__list').hidden); + const after=await page.$eval('.chat-page__input-wrap',el=>el.getBoundingClientRect().height); + assert.ok(afterel.textContent)).includes('8'),true); + } + await page.setViewport({width:390,height:844}); + await page.goto(server.origin+'/tests/fixtures/host-queue.html?count=2&expanded=false'); + await page.waitForSelector('.host-message-queue li'); + assert.equal(await page.$('.chat-page__input'),null,'queue controls also work alongside the collapsed composer'); + await page.click('[aria-label="排队消息说明"]'); + await page.waitForSelector('.host-message-queue__help'); + await page.click('[aria-label="排队消息说明"]'); + assert.equal(await page.$('.host-message-queue__help'),null); + await page.click('.host-message-queue__actions button'); + await page.waitForFunction(()=>document.querySelectorAll('.host-message-queue li').length===1); + await page.click('[aria-label="移出队列"]'); + await page.waitForFunction(()=>!document.querySelector('.host-message-queue')); + assert.ok(await page.$('.chat-page__composer'),'removing the last queued message preserves the composer'); + }finally{await browser.close();await server.close();} +});