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
36 changes: 36 additions & 0 deletions src/web-ui/src/flow_chat/components/HostPendingQueuePanel.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.host-pending-queue {
padding: var(--openbitfun-space-2) var(--openbitfun-space-3);
gap: var(--openbitfun-space-1);
font-size: var(--openbitfun-type-body-sm-font-size);

[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='header'] {
justify-content: space-between;
}
&__toggle { padding: 0; color: var(--openbitfun-color-content-secondary); }
&__note, &__error {
margin: 0;
font-size: var(--openbitfun-type-body-sm-font-size);
line-height: var(--openbitfun-type-body-sm-line-height);
color: var(--openbitfun-color-content-secondary);
overflow-wrap: anywhere;
}
[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='list'][hidden] { display: none; }
[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='item'] {
flex-shrink: 0;
grid-template-columns: minmax(0, 1fr) auto;
}
[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='content'] {
font-size: var(--openbitfun-type-body-sm-font-size);
line-height: var(--openbitfun-type-body-sm-line-height);
gap: 0;
}
[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='actions'] {
opacity: 1;
pointer-events: auto;
gap: var(--openbitfun-space-1);
}
&__status {
font-size: var(--openbitfun-type-meta-font-size);
color: var(--openbitfun-color-content-muted);
}
}
37 changes: 37 additions & 0 deletions src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,40 @@ it.each([false, true])('keeps a normal send hidden and shows recovery only on fa
container.remove();
}
});

it('keeps four queued messages compact, exposes actions, and toggles help independently', async () => {
const requests: string[] = [];
let items = Array.from({ length: 4 }, (_, index) => ({ turnId: String(index), displayContent: String(index + 1),
status: 'queued' as const, previewTruncated: false, attachmentCount: 0, agentType: 'Standard',
createdAtMs: 1, reason: null, targetTurnId: null, steeringId: null }));
const queue = new HostDialogQueue('scope', 'session', async request => {
requests.push(request.action);
if (request.action === 'cancel' || request.action === 'promote') items = items.filter(item => item.turnId !== request.turnId);
return { sessionId: 'session', queueEpoch: 'epoch', revision: requests.length, activeTurnId: 'active',
items, capacity: 20, used: items.length, receipt: null };
}, { list: async () => [], put: async () => {}, remove: async () => {} });
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<HostPendingQueuePanel queue={queue} onRestore={() => true} />));
expect(container.querySelectorAll('li')).toHaveLength(4);
expect(container.textContent).not.toContain('hostQueue.memoryNotice');
expect(container.textContent).not.toContain('hostQueue.queued');
expect(container.querySelectorAll('[aria-label="hostQueue.sendNow"]')).toHaveLength(4);
const toggle = container.querySelector<HTMLButtonElement>('.host-pending-queue__toggle')!;
await act(async () => toggle.click());
expect(container.querySelector('ul')?.hidden).toBe(true);
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="hostQueue.about"]')!.click());
expect(container.textContent).toContain('hostQueue.memoryNotice');
await act(async () => toggle.click());
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="hostQueue.sendNow"]')!.click());
expect(requests).toContain('promote');
expect(container.querySelectorAll('li')).toHaveLength(3);
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="hostQueue.cancel"]')!.click());
expect(container.querySelectorAll('li')).toHaveLength(2);
} finally {
await act(async () => root.unmount());
container.remove();
}
});
62 changes: 37 additions & 25 deletions src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { pendingQueueManager } from '../services/flow-chat-manager/PendingQueueModule';
import type { QueuedMessage } from '../types/flow-chat';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { useEffect, useId, useState, useSyncExternalStore } from 'react';
import { useI18n } from '@/infrastructure/i18n';
import { Button } from '@openbitfun/ui';
import { Button, IconButton, OverflowText, Tooltip } from '@openbitfun/ui';
import { ArrowUp, ChevronDown, ChevronUp, Info, Pencil, RotateCw, X } from 'lucide-react';
import './HostPendingQueuePanel.scss';
import { HostDialogQueue, observeHostQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue';
import {
ChatComposerQueue, ChatComposerQueueHeader, ChatComposerQueueTitle,
Expand All @@ -12,6 +14,9 @@ import {
export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQueue; onRestore: (item: QueuedMessage) => boolean }) {
const { t } = useI18n('flow-chat');
const view = useSyncExternalStore(queue.subscribe, queue.getSnapshot, queue.getSnapshot);
const [expanded, setExpanded] = useState(true);
const [showHelp, setShowHelp] = useState(false);
const listId = useId();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => observeHostQueue(queue), [queue]);
Expand Down Expand Up @@ -47,37 +52,44 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ
const items = view.snapshot?.items ?? [];
const visibleError = error || (view.error?.includes('Session is not loaded') ? null : view.error);
if (!items.length && !view.pending.length && !visibleError) return null;
return <ChatComposerQueue aria-label={t('hostQueue.title')}>
<ChatComposerQueueHeader><ChatComposerQueueTitle count={items.length}>{t('hostQueue.title')}</ChatComposerQueueTitle></ChatComposerQueueHeader>
<p>{t('hostQueue.memoryNotice')}</p>
{visibleError && <div role="alert">{visibleError}<Button size="sm" disabled={busy} onClick={() => void run(() => queue.refresh())}>{t('hostQueue.refresh')}</Button></div>}
<ChatComposerQueueList>
{items.map(item => <ChatComposerQueueItem key={item.turnId} state={item.status === 'blocked' ? 'failed' : item.status === 'steering_pending' ? 'sending' : 'default'}>
<ChatComposerQueueItemContent>
<div>{item.displayContent}</div>
<span>{item.status === 'blocked' ? t('hostQueue.blocked') : item.status === 'steering_pending' ? t('hostQueue.steeringPending') : t('hostQueue.queued')}</span>
{item.attachmentCount > 0 && <span>{t('hostQueue.attachments', { count: item.attachmentCount })}</span>}
{item.reason && <p>{item.reason}</p>}
return <ChatComposerQueue data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="root" className="host-pending-queue" aria-label={t('hostQueue.title')}>
<ChatComposerQueueHeader data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="header">
<Button variant="text" size="xs" className="host-pending-queue__toggle" aria-expanded={expanded} aria-controls={listId}
onClick={() => setExpanded(value => !value)} trailingIcon={expanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}>
<ChatComposerQueueTitle data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="title" count={items.length + view.pending.length}>{t('hostQueue.title')}</ChatComposerQueueTitle>
</Button>
<Tooltip content={t('hostQueue.about')}><IconButton size="xs" aria-label={t('hostQueue.about')} aria-expanded={showHelp}
icon={<Info size={14} />} onClick={() => setShowHelp(value => !value)} /></Tooltip>
</ChatComposerQueueHeader>
{showHelp && <p data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="status" className="host-pending-queue__note">{t('hostQueue.memoryNotice')}</p>}
{visibleError && <div data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="status" role="alert" className="host-pending-queue__error">{visibleError}<Button size="xs" variant="text" disabled={busy} onClick={() => void run(() => queue.refresh())}>{t('hostQueue.refresh')}</Button></div>}
<ChatComposerQueueList data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="list" id={listId} hidden={!expanded}>
{items.map(item => <ChatComposerQueueItem data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="item" key={item.turnId} state={item.status === 'blocked' ? 'failed' : item.status === 'steering_pending' ? 'sending' : 'default'}>
<ChatComposerQueueItemContent data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="content">
<OverflowText title={item.displayContent}>{item.displayContent || t('pendingQueue.emptyPlaceholder')}</OverflowText>
{item.status !== 'queued' && <span className="host-pending-queue__status">{item.status === 'blocked' ? t('hostQueue.blocked') : t('hostQueue.steeringPending')}</span>}
{item.attachmentCount > 0 && <span className="host-pending-queue__status">{t('hostQueue.attachments', { count: item.attachmentCount })}</span>}
{item.reason && <OverflowText className="host-pending-queue__status" title={item.reason}>{item.reason}</OverflowText>}
</ChatComposerQueueItemContent>
<ChatComposerQueueItemActions>
<Button size="sm" disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(async () => {
<ChatComposerQueueItemActions data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="actions">
<Tooltip content={t('hostQueue.edit')}><IconButton size="xs" aria-label={t('hostQueue.edit')} icon={<Pencil size={14} />} disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(async () => {
const saved = await queue.savedDraft(item.turnId);
if (!saved || saved.request.action !== 'submit') throw new Error(t('hostQueue.noDraft'));
await restoreDraft(saved, item);
})}>{t('hostQueue.edit')}</Button>
<Button size="sm" disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'promote'))}>{t('hostQueue.sendNow')}</Button>
<Button size="sm" disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'cancel'))}>{t('hostQueue.cancel')}</Button>
})} /></Tooltip>
<Tooltip content={t('hostQueue.sendNow')}><IconButton size="xs" aria-label={t('hostQueue.sendNow')} icon={<ArrowUp size={14} />} disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'promote'))} /></Tooltip>
<Tooltip content={t('hostQueue.cancel')}><IconButton size="xs" aria-label={t('hostQueue.cancel')} icon={<X size={14} />} disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'cancel'))} /></Tooltip>
</ChatComposerQueueItemActions>
</ChatComposerQueueItem>)}
{view.pending.map(record => <ChatComposerQueueItem key={record.key} state="failed">
<ChatComposerQueueItemContent>{t('hostQueue.unknown')}
{record.request.action === 'submit' && <div>{record.request.message.displayContent ?? record.request.message.content}</div>}
{view.pending.map(record => <ChatComposerQueueItem data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="item" key={record.key} state="failed">
<ChatComposerQueueItemContent data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="content"><OverflowText className="host-pending-queue__status" title={t('hostQueue.unknown')}>{t('hostQueue.unknown')}</OverflowText>
{record.request.action === 'submit' && <OverflowText title={record.request.message.displayContent ?? record.request.message.content}>{record.request.message.displayContent ?? record.request.message.content}</OverflowText>}
</ChatComposerQueueItemContent>
<ChatComposerQueueItemActions>
<ChatComposerQueueItemActions data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="actions">
{record.restoreIntent && record.request.action === 'submit'
? <Button size="sm" disabled={busy} onClick={() => void run(() => restoreDraft(record))}>{t('hostQueue.edit')}</Button>
: <Button size="sm" disabled={busy} onClick={() => void run(() => queue.retry(record))}>{t('hostQueue.checkRetry')}</Button>}
<Button size="sm" disabled={busy} onClick={() => void run(() => queue.dismiss(record))}>{t('hostQueue.dismiss')}</Button>
? <IconButton size="xs" aria-label={t('hostQueue.edit')} title={t('hostQueue.edit')} icon={<Pencil size={14} />} disabled={busy} onClick={() => void run(() => restoreDraft(record))} />
: <IconButton size="xs" aria-label={t('hostQueue.checkRetry')} title={t('hostQueue.checkRetry')} icon={<RotateCw size={14} />} disabled={busy} onClick={() => void run(() => queue.retry(record))} />}
<IconButton size="xs" aria-label={t('hostQueue.dismiss')} title={t('hostQueue.dismiss')} icon={<X size={14} />} disabled={busy} onClick={() => void run(() => queue.dismiss(record))} />
</ChatComposerQueueItemActions>
</ChatComposerQueueItem>)}
</ChatComposerQueueList>
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2881,7 +2881,8 @@
"loading": "Opening assistant…"
},
"hostQueue": {
"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
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2881,7 +2881,8 @@
"loading": "正在打开助手…"
},
"hostQueue": {
"title": "宿主消息队列",
"title": "待发送",
"about": "排队消息说明",
"memoryNotice": "消息接受后,关闭此页面仍会执行。执行设备重启会清空待执行消息。",
"queued": "排队中",
"blocked": "等待恢复",
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2881,7 +2881,8 @@
"loading": "正在開啟助手…"
},
"hostQueue": {
"title": "主機訊息佇列",
"title": "待傳送",
"about": "排隊訊息說明",
"memoryNotice": "訊息接受後,關閉此頁面仍會執行。執行裝置重新啟動會清空待執行訊息。",
"queued": "排隊中",
"blocked": "等待恢復",
Expand Down
Loading