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
3 changes: 3 additions & 0 deletions src/mobile-web/src/components/ChatComposerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface ChatComposerBarProps {
input: string;
inputRef: React.Ref<HTMLTextAreaElement>;
modelControls: React.ReactNode;
queueContent?: React.ReactNode;
onActivate: () => void;
onAttach: () => void;
onCancel: () => void;
Expand All @@ -44,6 +45,7 @@ export default function ChatComposerBar({
input,
inputRef,
modelControls,
queueContent,
onActivate,
onAttach,
onCancel,
Expand All @@ -62,6 +64,7 @@ export default function ChatComposerBar({

return (
<div className={`chat-page__input-wrap ${expanded ? 'is-expanded' : ''}`} ref={containerRef}>
{queueContent}
<MobileComposer
aria-label={t('chat.collapsedInputPlaceholder')}
className="chat-page__composer"
Expand Down
77 changes: 48 additions & 29 deletions src/mobile-web/src/components/MobileHostQueue.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import React, { useEffect, useState, useSyncExternalStore } from 'react';
import { MobileButton, MobileBanner } from '@openbitfun/ui/mobile';
import React, { useEffect, useId, useState, useSyncExternalStore } from 'react';
import { MobileButton, MobileBanner, MobileIconButton } from '@openbitfun/ui/mobile';
import { HostDialogQueue, observeHostQueue } from '../../../shared/dialog-queue/HostDialogQueue';
import { useI18n } from '../i18n';
import { ArrowUp, ChevronDown, ChevronUp, Info, X } from 'lucide-react';
import '../styles/host-queue.scss';

export function MobileHostQueue({ queue, onRestore }: { queue: HostDialogQueue; onRestore: (text: string) => 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<string | null>(null);
useEffect(() => observeHostQueue(queue), [queue]);
Expand All @@ -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 <section className="host-message-queue" aria-label={t('queue.title')}>
<strong>{t('queue.title')}</strong>
<p>{t('queue.memoryNotice')}</p>
{(error || view.error) && <MobileBanner tone="danger" role="alert">{error || view.error}</MobileBanner>}
{(error || view.error) && <MobileButton size="sm" disabled={busy} onClick={() => void run(() => queue.refresh())}>{t('queue.refresh')}</MobileButton>}
<ul>
{view.snapshot?.items.map(item => <li key={item.turnId}>
<p className="host-message-queue__preview">{item.displayContent}</p>
<span>{item.status === 'steering_pending' ? t('queue.steeringPending') : item.status === 'blocked' ? t('queue.blocked') : t('queue.queued')}</span>
{item.attachmentCount > 0 && <span>{t('queue.attachments', { count: item.attachmentCount })}</span>}
{item.reason && <p>{item.reason}</p>}
<div className="host-message-queue__actions">
<MobileButton size="sm" disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'promote'))}>{t('queue.sendNow')}</MobileButton>
<MobileButton size="sm" disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'cancel'))}>{t('queue.cancel')}</MobileButton>
</div>
</li>)}
{view.pending.map(record => <li key={record.key}>
<p>{t('queue.unknown')}</p>
{record.request.action === 'submit' && <p className="host-message-queue__preview">{record.request.message.displayContent ?? record.request.message.content}</p>}
<div className="host-message-queue__actions">
<MobileButton size="sm" disabled={busy} onClick={() => void run(() => queue.retry(record))}>{t('queue.checkRetry')}</MobileButton>
{record.request.action === 'submit' && <MobileButton size="sm" disabled={busy} onClick={() => {
if (record.request.action === 'submit') onRestore(record.request.message.content);
}}>{t('queue.copyDraft')}</MobileButton>}
<MobileButton size="sm" disabled={busy} onClick={() => void run(() => queue.dismiss(record))}>{t('queue.dismiss')}</MobileButton>
</div>
</li>)}
</ul>
<div className="host-message-queue__header">
<MobileButton appearance="plain" size="sm" className="host-message-queue__toggle" aria-expanded={expanded} aria-controls={listId}
onClick={() => setExpanded(value => !value)}>
<span>{t('queue.title')}</span>
<span className="host-message-queue__count">{t('common.itemCount', { count })}</span>
{expanded ? <ChevronDown size={16} aria-hidden="true" /> : <ChevronUp size={16} aria-hidden="true" />}
</MobileButton>
<MobileIconButton appearance="plain" size="sm" className="host-message-queue__icon" aria-label={t('queue.about')} aria-expanded={showHelp} aria-controls={helpId}
icon={<Info size={16} aria-hidden="true" />} onClick={() => setShowHelp(value => !value)} />
</div>
<div className="host-message-queue__body">
{showHelp && <p id={helpId} className="host-message-queue__help">{t('queue.memoryNotice')}</p>}
{(error || view.error) && <MobileBanner tone="danger" role="alert">{error || view.error}</MobileBanner>}
{(error || view.error) && <MobileButton appearance="plain" size="sm" disabled={busy} onClick={() => void run(() => queue.refresh())}>{t('queue.refresh')}</MobileButton>}
<ul id={listId} hidden={!expanded} className="host-message-queue__list">
{view.snapshot?.items.map(item => <li key={item.turnId} className="host-message-queue__row">
<div className="host-message-queue__message">
<p className="host-message-queue__preview">{item.displayContent}</p>
{item.status !== 'queued' && <span className="host-message-queue__status">{item.status === 'steering_pending' ? t('queue.steeringPending') : t('queue.blocked')}</span>}
{item.attachmentCount > 0 && <span>{t('queue.attachments', { count: item.attachmentCount })}</span>}
{item.reason && <p>{item.reason}</p>}
</div>
<div className="host-message-queue__actions">
<MobileIconButton appearance="plain" size="sm" className="host-message-queue__icon" aria-label={t('queue.sendNow')} title={t('queue.sendNow')} disabled={busy || !!view.error || item.status === 'steering_pending'} icon={<ArrowUp size={16} aria-hidden="true" />} onClick={() => void run(() => queue.act(item, 'promote'))} />
<MobileIconButton appearance="plain" size="sm" className="host-message-queue__icon" aria-label={t('queue.cancel')} title={t('queue.cancel')} disabled={busy || !!view.error || item.status === 'steering_pending'} icon={<X size={16} aria-hidden="true" />} onClick={() => void run(() => queue.act(item, 'cancel'))} />
</div>
</li>)}
{view.pending.map(record => <li key={record.key}>
<p>{t('queue.unknown')}</p>
{record.request.action === 'submit' && <p className="host-message-queue__preview">{record.request.message.displayContent ?? record.request.message.content}</p>}
<div className="host-message-queue__actions">
<MobileButton appearance="plain" size="sm" disabled={busy} onClick={() => void run(() => queue.retry(record))}>{t('queue.checkRetry')}</MobileButton>
{record.request.action === 'submit' && <MobileButton appearance="plain" size="sm" disabled={busy} onClick={() => {
if (record.request.action === 'submit') onRestore(record.request.message.content);
}}>{t('queue.copyDraft')}</MobileButton>}
<MobileButton appearance="plain" size="sm" disabled={busy} onClick={() => void run(() => queue.dismiss(record))}>{t('queue.dismiss')}</MobileButton>
</div>
</li>)}
</ul>
</div>
</section>;
}
9 changes: 6 additions & 3 deletions src/mobile-web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ type MessageTree = { readonly [key: string]: MessageLeaf | MessageTree };
export const messages: Record<MobileLanguage, MessageTree> = {
'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",
Expand Down Expand Up @@ -373,7 +374,8 @@ export const messages: Record<MobileLanguage, MessageTree> = {
},
'zh-CN': {
queue: {
"title": "宿主消息队列",
"title": "待发送",
"about": "排队消息说明",
"memoryNotice": "消息接受后,关闭此页面仍会执行。执行设备重启会清空待执行消息。",
"queued": "排队中",
"blocked": "等待恢复",
Expand Down Expand Up @@ -737,7 +739,8 @@ export const messages: Record<MobileLanguage, MessageTree> = {
},
'zh-TW': {
queue: {
"title": "主機訊息佇列",
"title": "待傳送",
"about": "排隊訊息說明",
"memoryNotice": "訊息接受後,關閉此頁面仍會執行。執行裝置重新啟動會清空待執行訊息。",
"queued": "排隊中",
"blocked": "等待恢復",
Expand Down
4 changes: 2 additions & 2 deletions src/mobile-web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1274,9 +1274,9 @@ const ChatPage: React.FC<ChatPageProps> = ({
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{hostQueue && <MobileHostQueue key={`${sessionId}:${controlTargetEpoch}`} queue={hostQueue}
onRestore={content => { setInput(current => current ? `${current}\n\n${content}` : content); setInputExpanded(true); }} />}
<ChatComposerBar
queueContent={hostQueue && <MobileHostQueue key={`${sessionId}:${controlTargetEpoch}`} queue={hostQueue}
onRestore={content => { setInput(current => current ? `${current}\n\n${content}` : content); setInputExpanded(true); }} />}
cancelling={isCancelling}
containerRef={inputBarRef}
expanded={inputExpanded}
Expand Down
2 changes: 2 additions & 0 deletions src/mobile-web/src/styles/components/chat-input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
68 changes: 59 additions & 9 deletions src/mobile-web/src/styles/host-queue.scss
Original file line number Diff line number Diff line change
@@ -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; }
}
9 changes: 9 additions & 0 deletions src/mobile-web/tests/fixtures/host-queue.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="zh-CN"><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mobile queue layout</title></head>
<body><script type="module">
import { mountHostQueueFixture } from './host-queue.tsx';
const params = new URLSearchParams(location.search);
localStorage.setItem('openbitfun-mobile-language', params.get('lang') ?? 'zh-CN');
document.documentElement.setAttribute('data-color-scheme', params.get('theme') ?? 'dark');
mountHostQueueFixture({ count: Number(params.get('count') ?? 1), expanded: params.get('expanded') !== 'false' });
</script></body></html>
38 changes: 30 additions & 8 deletions src/mobile-web/tests/fixtures/host-queue.tsx
Original file line number Diff line number Diff line change
@@ -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(<I18nProvider><MobileHostQueue queue={queue} onRestore={noop} />
<ChatComposerBar cancelling={false} containerRef={null} expanded imageAnalyzing={false} sending={false}
function Fixture() {
useMobileViewport();
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(56);
useLayoutEffect(() => {
const observer = new ResizeObserver(() => setHeight(ref.current!.getBoundingClientRect().height));
observer.observe(ref.current!);
return () => observer.disconnect();
}, []);
return <ThemeProvider><I18nProvider><div className={`chat-page${window.innerWidth >= 900 ? ' chat-page--wide' : ''}`} style={{ '--chat-composer-height': `${height}px` } as React.CSSProperties}>
<header className="chat-page__header">项目介绍</header>
<div className="chat-page__messages"><p>正在检查代码和测试覆盖。</p><p>ExecCommand</p><p>分析当前项目的实现。</p></div>
<ChatComposerBar queueContent={<MobileHostQueue queue={queue} onRestore={noop} />}
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 />
</I18nProvider>);
</div></I18nProvider></ThemeProvider>;
}
root.render(<Fixture />);
return { calls, dispose: () => root.unmount() };
}
Loading
Loading