Skip to content

Commit df4e790

Browse files
committed
fix(desktop): make host message queue compact and readable
1 parent faf5d4a commit df4e790

6 files changed

Lines changed: 116 additions & 28 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
.host-pending-queue {
2+
padding: var(--openbitfun-space-2) var(--openbitfun-space-3);
3+
gap: var(--openbitfun-space-1);
4+
font-size: var(--openbitfun-type-body-sm-font-size);
5+
6+
[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='header'] {
7+
justify-content: space-between;
8+
}
9+
&__toggle { padding: 0; color: var(--openbitfun-color-content-secondary); }
10+
&__note, &__error {
11+
margin: 0;
12+
font-size: var(--openbitfun-type-body-sm-font-size);
13+
line-height: var(--openbitfun-type-body-sm-line-height);
14+
color: var(--openbitfun-color-content-secondary);
15+
overflow-wrap: anywhere;
16+
}
17+
[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='list'][hidden] { display: none; }
18+
[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='item'] {
19+
flex-shrink: 0;
20+
grid-template-columns: minmax(0, 1fr) auto;
21+
}
22+
[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='content'] {
23+
font-size: var(--openbitfun-type-body-sm-font-size);
24+
line-height: var(--openbitfun-type-body-sm-line-height);
25+
gap: 0;
26+
}
27+
[data-openbitfun-component='chat-composer-queue'][data-openbitfun-part='actions'] {
28+
opacity: 1;
29+
pointer-events: auto;
30+
gap: var(--openbitfun-space-1);
31+
}
32+
&__status {
33+
font-size: var(--openbitfun-type-meta-font-size);
34+
color: var(--openbitfun-color-content-muted);
35+
}
36+
}

‎src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,40 @@ it.each([false, true])('keeps a normal send hidden and shows recovery only on fa
5656
container.remove();
5757
}
5858
});
59+
60+
it('keeps four queued messages compact, exposes actions, and toggles help independently', async () => {
61+
const requests: string[] = [];
62+
let items = Array.from({ length: 4 }, (_, index) => ({ turnId: String(index), displayContent: String(index + 1),
63+
status: 'queued' as const, previewTruncated: false, attachmentCount: 0, agentType: 'Standard',
64+
createdAtMs: 1, reason: null, targetTurnId: null, steeringId: null }));
65+
const queue = new HostDialogQueue('scope', 'session', async request => {
66+
requests.push(request.action);
67+
if (request.action === 'cancel' || request.action === 'promote') items = items.filter(item => item.turnId !== request.turnId);
68+
return { sessionId: 'session', queueEpoch: 'epoch', revision: requests.length, activeTurnId: 'active',
69+
items, capacity: 20, used: items.length, receipt: null };
70+
}, { list: async () => [], put: async () => {}, remove: async () => {} });
71+
const container = document.createElement('div');
72+
document.body.append(container);
73+
const root = createRoot(container);
74+
try {
75+
await act(async () => root.render(<HostPendingQueuePanel queue={queue} onRestore={() => true} />));
76+
expect(container.querySelectorAll('li')).toHaveLength(4);
77+
expect(container.textContent).not.toContain('hostQueue.memoryNotice');
78+
expect(container.textContent).not.toContain('hostQueue.queued');
79+
expect(container.querySelectorAll('[aria-label="hostQueue.sendNow"]')).toHaveLength(4);
80+
const toggle = container.querySelector<HTMLButtonElement>('.host-pending-queue__toggle')!;
81+
await act(async () => toggle.click());
82+
expect(container.querySelector('ul')?.hidden).toBe(true);
83+
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="hostQueue.about"]')!.click());
84+
expect(container.textContent).toContain('hostQueue.memoryNotice');
85+
await act(async () => toggle.click());
86+
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="hostQueue.sendNow"]')!.click());
87+
expect(requests).toContain('promote');
88+
expect(container.querySelectorAll('li')).toHaveLength(3);
89+
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="hostQueue.cancel"]')!.click());
90+
expect(container.querySelectorAll('li')).toHaveLength(2);
91+
} finally {
92+
await act(async () => root.unmount());
93+
container.remove();
94+
}
95+
});

‎src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx‎

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { pendingQueueManager } from '../services/flow-chat-manager/PendingQueueModule';
22
import type { QueuedMessage } from '../types/flow-chat';
3-
import { useEffect, useState, useSyncExternalStore } from 'react';
3+
import { useEffect, useId, useState, useSyncExternalStore } from 'react';
44
import { useI18n } from '@/infrastructure/i18n';
5-
import { Button } from '@openbitfun/ui';
5+
import { Button, IconButton, OverflowText, Tooltip } from '@openbitfun/ui';
6+
import { ArrowUp, ChevronDown, ChevronUp, Info, Pencil, RotateCw, X } from 'lucide-react';
7+
import './HostPendingQueuePanel.scss';
68
import { HostDialogQueue, observeHostQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue';
79
import {
810
ChatComposerQueue, ChatComposerQueueHeader, ChatComposerQueueTitle,
@@ -12,6 +14,9 @@ import {
1214
export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQueue; onRestore: (item: QueuedMessage) => boolean }) {
1315
const { t } = useI18n('flow-chat');
1416
const view = useSyncExternalStore(queue.subscribe, queue.getSnapshot, queue.getSnapshot);
17+
const [expanded, setExpanded] = useState(true);
18+
const [showHelp, setShowHelp] = useState(false);
19+
const listId = useId();
1520
const [busy, setBusy] = useState(false);
1621
const [error, setError] = useState<string | null>(null);
1722
useEffect(() => observeHostQueue(queue), [queue]);
@@ -47,37 +52,44 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ
4752
const items = view.snapshot?.items ?? [];
4853
const visibleError = error || (view.error?.includes('Session is not loaded') ? null : view.error);
4954
if (!items.length && !view.pending.length && !visibleError) return null;
50-
return <ChatComposerQueue aria-label={t('hostQueue.title')}>
51-
<ChatComposerQueueHeader><ChatComposerQueueTitle count={items.length}>{t('hostQueue.title')}</ChatComposerQueueTitle></ChatComposerQueueHeader>
52-
<p>{t('hostQueue.memoryNotice')}</p>
53-
{visibleError && <div role="alert">{visibleError}<Button size="sm" disabled={busy} onClick={() => void run(() => queue.refresh())}>{t('hostQueue.refresh')}</Button></div>}
54-
<ChatComposerQueueList>
55-
{items.map(item => <ChatComposerQueueItem key={item.turnId} state={item.status === 'blocked' ? 'failed' : item.status === 'steering_pending' ? 'sending' : 'default'}>
56-
<ChatComposerQueueItemContent>
57-
<div>{item.displayContent}</div>
58-
<span>{item.status === 'blocked' ? t('hostQueue.blocked') : item.status === 'steering_pending' ? t('hostQueue.steeringPending') : t('hostQueue.queued')}</span>
59-
{item.attachmentCount > 0 && <span>{t('hostQueue.attachments', { count: item.attachmentCount })}</span>}
60-
{item.reason && <p>{item.reason}</p>}
55+
return <ChatComposerQueue data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="root" className="host-pending-queue" aria-label={t('hostQueue.title')}>
56+
<ChatComposerQueueHeader data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="header">
57+
<Button variant="text" size="xs" className="host-pending-queue__toggle" aria-expanded={expanded} aria-controls={listId}
58+
onClick={() => setExpanded(value => !value)} trailingIcon={expanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}>
59+
<ChatComposerQueueTitle data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="title" count={items.length + view.pending.length}>{t('hostQueue.title')}</ChatComposerQueueTitle>
60+
</Button>
61+
<Tooltip content={t('hostQueue.about')}><IconButton size="xs" aria-label={t('hostQueue.about')} aria-expanded={showHelp}
62+
icon={<Info size={14} />} onClick={() => setShowHelp(value => !value)} /></Tooltip>
63+
</ChatComposerQueueHeader>
64+
{showHelp && <p data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="status" className="host-pending-queue__note">{t('hostQueue.memoryNotice')}</p>}
65+
{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>}
66+
<ChatComposerQueueList data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="list" id={listId} hidden={!expanded}>
67+
{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'}>
68+
<ChatComposerQueueItemContent data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="content">
69+
<OverflowText title={item.displayContent}>{item.displayContent || t('pendingQueue.emptyPlaceholder')}</OverflowText>
70+
{item.status !== 'queued' && <span className="host-pending-queue__status">{item.status === 'blocked' ? t('hostQueue.blocked') : t('hostQueue.steeringPending')}</span>}
71+
{item.attachmentCount > 0 && <span className="host-pending-queue__status">{t('hostQueue.attachments', { count: item.attachmentCount })}</span>}
72+
{item.reason && <OverflowText className="host-pending-queue__status" title={item.reason}>{item.reason}</OverflowText>}
6173
</ChatComposerQueueItemContent>
62-
<ChatComposerQueueItemActions>
63-
<Button size="sm" disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(async () => {
74+
<ChatComposerQueueItemActions data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="actions">
75+
<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 () => {
6476
const saved = await queue.savedDraft(item.turnId);
6577
if (!saved || saved.request.action !== 'submit') throw new Error(t('hostQueue.noDraft'));
6678
await restoreDraft(saved, item);
67-
})}>{t('hostQueue.edit')}</Button>
68-
<Button size="sm" disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'promote'))}>{t('hostQueue.sendNow')}</Button>
69-
<Button size="sm" disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'cancel'))}>{t('hostQueue.cancel')}</Button>
79+
})} /></Tooltip>
80+
<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>
81+
<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>
7082
</ChatComposerQueueItemActions>
7183
</ChatComposerQueueItem>)}
72-
{view.pending.map(record => <ChatComposerQueueItem key={record.key} state="failed">
73-
<ChatComposerQueueItemContent>{t('hostQueue.unknown')}
74-
{record.request.action === 'submit' && <div>{record.request.message.displayContent ?? record.request.message.content}</div>}
84+
{view.pending.map(record => <ChatComposerQueueItem data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="item" key={record.key} state="failed">
85+
<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>
86+
{record.request.action === 'submit' && <OverflowText title={record.request.message.displayContent ?? record.request.message.content}>{record.request.message.displayContent ?? record.request.message.content}</OverflowText>}
7587
</ChatComposerQueueItemContent>
76-
<ChatComposerQueueItemActions>
88+
<ChatComposerQueueItemActions data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="actions">
7789
{record.restoreIntent && record.request.action === 'submit'
78-
? <Button size="sm" disabled={busy} onClick={() => void run(() => restoreDraft(record))}>{t('hostQueue.edit')}</Button>
79-
: <Button size="sm" disabled={busy} onClick={() => void run(() => queue.retry(record))}>{t('hostQueue.checkRetry')}</Button>}
80-
<Button size="sm" disabled={busy} onClick={() => void run(() => queue.dismiss(record))}>{t('hostQueue.dismiss')}</Button>
90+
? <IconButton size="xs" aria-label={t('hostQueue.edit')} title={t('hostQueue.edit')} icon={<Pencil size={14} />} disabled={busy} onClick={() => void run(() => restoreDraft(record))} />
91+
: <IconButton size="xs" aria-label={t('hostQueue.checkRetry')} title={t('hostQueue.checkRetry')} icon={<RotateCw size={14} />} disabled={busy} onClick={() => void run(() => queue.retry(record))} />}
92+
<IconButton size="xs" aria-label={t('hostQueue.dismiss')} title={t('hostQueue.dismiss')} icon={<X size={14} />} disabled={busy} onClick={() => void run(() => queue.dismiss(record))} />
8193
</ChatComposerQueueItemActions>
8294
</ChatComposerQueueItem>)}
8395
</ChatComposerQueueList>

‎src/web-ui/src/locales/en-US/flow-chat.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2881,7 +2881,8 @@
28812881
"loading": "Opening assistant…"
28822882
},
28832883
"hostQueue": {
2884-
"title": "Host message queue",
2884+
"title": "Queued messages",
2885+
"about": "About queued messages",
28852886
"memoryNotice": "Accepted messages run while this page is closed. Restarting the execution device clears pending messages.",
28862887
"queued": "Queued",
28872888
"blocked": "Waiting for recovery",

‎src/web-ui/src/locales/zh-CN/flow-chat.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2881,7 +2881,8 @@
28812881
"loading": "正在打开助手…"
28822882
},
28832883
"hostQueue": {
2884-
"title": "宿主消息队列",
2884+
"title": "待发送",
2885+
"about": "排队消息说明",
28852886
"memoryNotice": "消息接受后,关闭此页面仍会执行。执行设备重启会清空待执行消息。",
28862887
"queued": "排队中",
28872888
"blocked": "等待恢复",

‎src/web-ui/src/locales/zh-TW/flow-chat.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2881,7 +2881,8 @@
28812881
"loading": "正在開啟助手…"
28822882
},
28832883
"hostQueue": {
2884-
"title": "主機訊息佇列",
2884+
"title": "待傳送",
2885+
"about": "排隊訊息說明",
28852886
"memoryNotice": "訊息接受後,關閉此頁面仍會執行。執行裝置重新啟動會清空待執行訊息。",
28862887
"queued": "排隊中",
28872888
"blocked": "等待恢復",

0 commit comments

Comments
 (0)