From 258f07ef1d1ab71f20b10f192e0e728073866627 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 17:40:48 +0800 Subject: [PATCH 1/3] fix(chat): refine pending message queue presentation and recovery --- design-system/AGENTS.md | 8 + .../composer/ChatComposerQueue.module.css | 121 +++++-- .../flow-chat/composer/ChatComposerQueue.tsx | 56 +++- .../ui/src/flow-chat/composer/index.ts | 5 + .../packages/ui/tests/chat-composer.test.mjs | 39 +++ src/web-ui/AGENTS.md | 10 + .../components/HostPendingQueuePanel.scss | 44 +-- .../components/HostPendingQueuePanel.test.tsx | 117 +++++++ .../components/HostPendingQueuePanel.tsx | 127 +++++--- .../components/PendingQueuePanel.scss | 48 +-- .../components/PendingQueuePanel.test.tsx | 96 +++++- .../components/PendingQueuePanel.tsx | 307 +++++++++--------- src/web-ui/src/locales/en-US/flow-chat.json | 7 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 7 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 7 +- 15 files changed, 680 insertions(+), 319 deletions(-) diff --git a/design-system/AGENTS.md b/design-system/AGENTS.md index 5d6c846f1e..a65f055b98 100644 --- a/design-system/AGENTS.md +++ b/design-system/AGENTS.md @@ -48,6 +48,14 @@ For shared icon rendering or geometry changes, use the focused package checks: pnpm --dir design-system run build:ui && node --test design-system/packages/ui/tests/icon.test.mjs design-system/packages/ui/tests/icon-slots.test.mjs ``` +For composer geometry and queue anatomy, disclosure and status presentation: + +```bash +pnpm --dir design-system run build:ui && node --test design-system/packages/ui/tests/chat-composer.test.mjs +``` + +These package contracts do not establish native rendering or live remote behavior. + Do not use browser automation or mock screenshots as visual proof. Design Lab is the manual authoring surface; source, build, HTTP, and package checks do not establish final visual fidelity. For SplitView geometry, drag cancellation, and cached resize bounds: diff --git a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css index bf80bc5d95..f6eea3ac32 100644 --- a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css +++ b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css @@ -5,24 +5,32 @@ min-inline-size: 0; inline-size: 100%; flex-direction: column; - gap: var(--openbitfun-space-2); - padding-block: var(--openbitfun-space-3); - padding-inline: var(--openbitfun-space-4); + gap: var(--openbitfun-space-1); + padding: var(--openbitfun-space-1); border-radius: var(--openbitfun-radius-lg); color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-surface-subtle); font-family:var(--openbitfun-type-body-md-font-family); } + /* A composer with a context bar already tints the tray around its input. + The queue joins that tray instead of stacking a second translucent card. */ + :global([data-openbitfun-component="chat-composer"][data-has-context="true"]) .root { + padding-block-start: 0; + background: transparent; + } + .header { display: flex; min-inline-size: 0; + min-block-size: var(--openbitfun-control-chat-composer-control-height); align-items: center; gap: var(--openbitfun-space-1); + padding-inline: var(--openbitfun-space-2); color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); font-weight: var(--openbitfun-type-body-md-font-weight); - line-height: var(--openbitfun-type-body-md-line-height); + line-height: var(--openbitfun-type-body-sm-line-height); } .header > :where(svg, img) { @@ -32,6 +40,12 @@ color: var(--openbitfun-color-content-muted); } + .toggle[data-openbitfun-component="button"][data-size] { + min-inline-size: 0; + padding-inline: 0; + color: inherit; + } + .title { display: inline-flex; min-inline-size: 0; @@ -73,31 +87,54 @@ list-style: none; } + .list[hidden] { + display: none; + } + .item { box-sizing: border-box; display: grid; + flex: 0 0 auto; min-inline-size: 0; min-block-size: var(--openbitfun-control-chat-composer-control-height); grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: var(--openbitfun-space-2); + padding-inline: var(--openbitfun-space-2) var(--openbitfun-space-1); + border-radius: var(--openbitfun-radius-md); color: var(--openbitfun-color-content-primary); } - .item[data-openbitfun-state="sending"] { - opacity: var(--openbitfun-opacity-disabled); + /* The row, not a floating button cluster, is the unit being acted on. */ + .item:focus-within { + background: var(--openbitfun-color-surface-subtle); } .content { display: flex; min-inline-size: 0; - flex-direction: column; - gap: var(--openbitfun-space-1); + flex-flow: row wrap; + align-items: baseline; + gap: 0 var(--openbitfun-space-2); color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-body-md-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); font-weight: var(--openbitfun-type-body-md-font-weight); - line-height: var(--openbitfun-type-body-md-line-height); - transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + line-height: var(--openbitfun-type-body-sm-line-height); + } + + /* Message text takes the line; status facts follow it and wrap below only + when the row is too narrow to hold both. */ + :where(.content) > * { + flex: 1 1 8em; + min-inline-size: 0; + } + + /* A truncated preview may be a tab stop so keyboard users can read its + full-text tooltip; it stays display-only. */ + :where(.content) > :focus-visible { + border-radius: var(--openbitfun-radius-xs); + outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); + outline-offset: var(--openbitfun-focus-offset); } .item:hover .content, @@ -105,32 +142,54 @@ color: var(--openbitfun-color-content-primary); } + /* Muted ink instead of row opacity keeps the status text legible. */ + .item[data-openbitfun-state="sending"] .content { + color: var(--openbitfun-color-content-muted); + } + + .status { + flex: 0 1 auto; + min-inline-size: 0; + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-meta-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + overflow-wrap: anywhere; + } + + .status[data-openbitfun-tone="warning"] { + color: var(--openbitfun-color-status-warning-content); + } + + .status[data-openbitfun-tone="danger"] { + color: var(--openbitfun-color-status-danger-content); + } + .attachmentBadge { - box-sizing: border-box; display: inline-flex; - min-inline-size: var(--openbitfun-control-chat-composer-control-height); - block-size: var(--openbitfun-control-chat-composer-control-height); + flex: 0 0 auto; align-items: center; - justify-content: center; - padding-inline: var(--openbitfun-space-2); - border-radius: var(--openbitfun-radius-pill); - color: var(--openbitfun-color-content-secondary); - background: var(--openbitfun-color-surface-raised); - font-size: var(--openbitfun-type-body-sm-font-size); - font-weight: var(--openbitfun-type-label-lg-font-weight); + gap: var(--openbitfun-space-1); + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-meta-font-size); font-variant-numeric: tabular-nums; line-height: var(--openbitfun-type-modifier-leading-none-line-height); } .actions { display: inline-flex; + flex: 0 0 auto; min-inline-size: 0; align-items: center; gap: var(--openbitfun-space-1); - transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + /* Reveal is instant: this is a high-frequency pointer path. Rows that need a + decision or show progress keep their actions visible; focus reaches the rest. */ @media (hover: hover) and (pointer: fine) { + .item:hover { + background: var(--openbitfun-color-surface-subtle); + } + .actions { pointer-events: none; opacity: 0; @@ -141,18 +200,24 @@ pointer-events: auto; opacity: 1; } + + .item:is([data-openbitfun-state="failed"], [data-openbitfun-state="sending"]) .actions { + pointer-events: auto; + opacity: 1; + } } - @media (prefers-reduced-motion: reduce) { - .content, - .actions { - transition: none; + @media (pointer: coarse) { + .actions [data-openbitfun-component="icon-button"] { + inline-size: var(--openbitfun-control-height-sm); + block-size: var(--openbitfun-control-height-sm); + min-inline-size: var(--openbitfun-control-height-sm); + min-block-size: var(--openbitfun-control-height-sm); } } @media (forced-colors: active) { - .root, - .attachmentBadge { + .root { border: var(--openbitfun-border-width-default) solid CanvasText; } } diff --git a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx index 798b3022d7..8aae002e32 100644 --- a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx +++ b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx @@ -1,3 +1,5 @@ +import { Button, type ButtonProps } from "../../components/Button"; +import { Icon } from "../../components/Icon"; import { OverflowText } from '../../primitives/OverflowText'; import { forwardRef, @@ -24,6 +26,11 @@ export interface ChatComposerQueueTitleProps count?: ReactNode; } +export interface ChatComposerQueueToggleProps + extends Omit { + expanded: boolean; +} + export interface ChatComposerQueueListProps extends HTMLAttributes {} @@ -35,9 +42,18 @@ export interface ChatComposerQueueItemProps export interface ChatComposerQueueItemContentProps extends HTMLAttributes {} +export type ChatComposerQueueItemStatusTone = "neutral" | "warning" | "danger"; + +export interface ChatComposerQueueItemStatusProps + extends HTMLAttributes { + tone?: ChatComposerQueueItemStatusTone; +} + export interface ChatComposerQueueAttachmentBadgeProps extends Omit, "children"> { count: number; + /** Supply a real icon from the consumer's icon library. */ + icon?: ReactNode; label: string; } @@ -102,6 +118,25 @@ export const ChatComposerQueueTitle = forwardRef< ); }); +/** Disclosure for the message list; wrap a ChatComposerQueueTitle as its label. */ +export const ChatComposerQueueToggle = forwardRef< + HTMLButtonElement, + ChatComposerQueueToggleProps +>(function ChatComposerQueueToggle({ className, expanded, ...props }, ref) { + return ( + - + } onClick={() => setShowHelp(value => !value)} /> {showHelp &&

{t('hostQueue.memoryNotice')}

} - {visibleError &&
{visibleError}
} + {visibleError &&
+ {visibleError} + +
} + {(recoveryItems.length > 0 || recoveryPending.length > 0) && + {recoveryItems.map(renderItem)}{recoveryPending.map(renderPending)} + } ; } diff --git a/src/web-ui/src/flow_chat/components/PendingQueuePanel.scss b/src/web-ui/src/flow_chat/components/PendingQueuePanel.scss index dac3069701..e67df65437 100644 --- a/src/web-ui/src/flow_chat/components/PendingQueuePanel.scss +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.scss @@ -1,50 +1,10 @@ +// Geometry, rows, status tones and action reveal come from ChatComposerQueue. .openbitfun-pending-queue-panel { - &__item { - position: relative; - - &--sending { - pointer-events: none; - } - } - - &__content { - min-width: 0; - } - - &__preview { - display: block; - min-width: 0; - overflow: hidden; - font-size: var(--openbitfun-type-body-md-font-size); - line-height: var(--openbitfun-type-body-sm-line-height); - white-space: nowrap; - } - - &__preview-empty { - color: var(--openbitfun-color-content-muted); - font-style: italic; - } - - &__sending-label, - &__failed-label { + &__legacy-note { + margin: 0; + padding-inline: var(--openbitfun-space-3); font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-body-sm-line-height); - } - - &__sending-label { color: var(--openbitfun-color-content-muted); } - - &__failed-label { - color: var(--openbitfun-color-status-danger-content); - font-weight: var(--openbitfun-type-label-sm-font-weight); - } - - &__actions { - flex: 0 0 auto; - } - - &__btn--danger:hover:not(:disabled) { - color: var(--openbitfun-color-status-danger-content); - } } diff --git a/src/web-ui/src/flow_chat/components/PendingQueuePanel.test.tsx b/src/web-ui/src/flow_chat/components/PendingQueuePanel.test.tsx index 508d31da66..dcd11d3954 100644 --- a/src/web-ui/src/flow_chat/components/PendingQueuePanel.test.tsx +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.test.tsx @@ -15,8 +15,20 @@ const mocks = vi.hoisted(() => ({ queueRemove: vi.fn(), queueSetStatus: vi.fn(), steerDialogTurn: vi.fn(), + items: [] as Array>, })); +const queuedItem = { + id: 'queued-1', + sessionId: 'session-1', + content: 'steer this turn', + timestamp: 1, + status: 'queued', + retryCount: 0, + imageContexts: [{ id: 'image-1' }, { id: 'image-2' }, { id: 'image-3' }], + imageDisplayData: [{ id: 'image-1' }, { id: 'image-2' }, { id: 'image-3' }], +}; + vi.mock('../services/hostDialogQueue', () => ({ hostQueueSupported: () => false })); vi.mock('./HostPendingQueuePanel', () => ({ HostPendingQueuePanel: () => null })); @@ -59,18 +71,7 @@ vi.mock('../store/FlowChatStore', () => ({ vi.mock('../services/flow-chat-manager/PendingQueueModule', () => ({ pendingQueueManager: { - list: () => [ - { - id: 'queued-1', - sessionId: 'session-1', - content: 'steer this turn', - timestamp: 1, - status: 'queued', - retryCount: 0, - imageContexts: [{ id: 'image-1' }, { id: 'image-2' }, { id: 'image-3' }], - imageDisplayData: [{ id: 'image-1' }, { id: 'image-2' }, { id: 'image-3' }], - }, - ], + list: () => mocks.items, subscribe: () => () => undefined, setStatus: mocks.queueSetStatus, remove: mocks.queueRemove, @@ -126,6 +127,7 @@ describe('PendingQueuePanel', () => { document.body.appendChild(container); root = createRoot(container); mocks.drainPendingQueueForSession.mockResolvedValue(undefined); + mocks.items = [queuedItem]; }); afterEach(() => { @@ -149,6 +151,7 @@ describe('PendingQueuePanel', () => { expect(title?.textContent).toBe('pendingQueue.title1'); expect(attachmentBadge?.textContent).toBe('3'); + expect(attachmentBadge?.getAttribute('role')).toBe('img'); expect(attachmentBadge?.getAttribute('aria-label')).toBe('pendingQueue.attachmentCount'); }); @@ -203,9 +206,16 @@ describe('PendingQueuePanel', () => { ); expect(preview).not.toBeNull(); expect(preview?.getAttribute('role')).toBeNull(); - expect(preview?.getAttribute('tabindex')).toBeNull(); + // A tab stop so the overflow tooltip can follow keyboard focus. + expect(preview?.tabIndex).toBe(0); - act(() => preview!.click()); + act(() => { + preview!.focus(); + preview!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + preview!.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + preview!.click(); + }); + expect(document.activeElement).toBe(preview); expect(onRestoreToComposer).not.toHaveBeenCalled(); expect(mocks.queueRemove).not.toHaveBeenCalled(); @@ -255,4 +265,62 @@ describe('PendingQueuePanel', () => { expect(onRestoreToComposer).toHaveBeenCalledTimes(1); expect(mocks.queueRemove).not.toHaveBeenCalled(); }); + + it('collapses the controlled list and keeps failed rows in a recovery list', async () => { + mocks.items = [ + queuedItem, + { id: 'failed-1', sessionId: 'session-1', content: 'retry me', timestamp: 2, status: 'failed', retryCount: 1 }, + ]; + await act(async () => { + root.render( true} />); + }); + + const toggle = container.querySelector('button[aria-expanded]')!; + const controlled = () => document.getElementById(toggle.getAttribute('aria-controls')!)!; + const count = () => container.querySelector('[data-openbitfun-part="totalCount"]')?.textContent; + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(controlled().hidden).toBe(false); + expect([...controlled().querySelectorAll('li')].map(row => row.textContent)).toEqual([ + expect.stringContaining('steer this turn'), expect.stringContaining('retry me'), + ]); + expect(count()).toBe('2'); + + act(() => toggle.click()); + + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(controlled().hidden).toBe(true); + expect(controlled().querySelectorAll('li')).toHaveLength(0); + expect(count()).toBe('2'); + const recovery = [...container.querySelectorAll('ul')].filter(list => list !== controlled()); + expect(recovery).toHaveLength(1); + const rows = recovery[0].querySelectorAll('li'); + expect(rows).toHaveLength(1); + expect(rows[0].textContent).toContain('retry me'); + expect(rows[0].textContent).toContain('pendingQueue.statusFailed'); + expect(rows[0].querySelector('button[aria-label="pendingQueue.actions.delete"]')).not.toBeNull(); + expect(container.textContent).not.toContain('steer this turn'); + + act(() => toggle.click()); + expect(controlled().hidden).toBe(false); + expect(controlled().querySelectorAll('li')).toHaveLength(2); + expect(container.querySelectorAll('ul')).toHaveLength(1); + }); + + it('shows a retried item that is sending again as sending, not failed', async () => { + mocks.items = [ + { id: 'retry-1', sessionId: 'session-1', content: 'second attempt', timestamp: 2, status: 'sending', retryCount: 1 }, + ]; + await act(async () => { + root.render( true} />); + }); + + const row = container.querySelector('li')!; + expect(row.getAttribute('data-openbitfun-state')).toBe('sending'); + expect(row.textContent).toContain('pendingQueue.statusQueuedSending'); + expect(row.textContent).not.toContain('pendingQueue.statusFailed'); + expect(row.querySelector('[data-openbitfun-tone="danger"]')).toBeNull(); + + act(() => container.querySelector('button[aria-expanded]')!.click()); + expect(container.textContent).not.toContain('second attempt'); + }); }); diff --git a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx index 94e3311524..6c60b923af 100644 --- a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx @@ -17,9 +17,9 @@ import { getActiveSurfaceScope, isSurfaceChangedError, onSurfaceActivated } from * deduped via `steeringId`. */ -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; -import { ListEnd } from 'lucide-react'; +import { Paperclip } from 'lucide-react'; import { OverflowText, Tooltip } from '@openbitfun/ui'; import { stateMachineManager } from '../state-machine'; import { FlowChatStore } from '../store/FlowChatStore'; @@ -41,8 +41,10 @@ import { ChatComposerQueueItem, ChatComposerQueueItemActions, ChatComposerQueueItemContent, + ChatComposerQueueItemStatus, ChatComposerQueueList, ChatComposerQueueTitle, + ChatComposerQueueToggle, type ChatComposerQueueItemState, } from '@openbitfun/ui/flow-chat'; @@ -54,6 +56,16 @@ interface PendingQueuePanelProps { onRestoreToComposer: (item: QueuedMessage) => boolean; } +function isSendingQueuedMessage(item: QueuedMessage): boolean { + return item.status === 'sending' || item.status === 'sending_now'; +} + +/** A send in progress outranks a failure from an earlier attempt. */ +function isFailedQueuedMessage(item: QueuedMessage): boolean { + return !isSendingQueuedMessage(item) + && (item.status === 'failed' || (item.retryCount ?? 0) > 0); +} + function LegacyPendingQueuePanel({ sessionId, className, @@ -61,6 +73,8 @@ function LegacyPendingQueuePanel({ }: PendingQueuePanelProps): JSX.Element | null { const { t } = useTranslation('flow-chat'); const sendNowInFlightIdsRef = useRef(new Set()); + const listId = useId(); + const [expanded, setExpanded] = useState(true); useSyncExternalStore( interruptedTurnRecoveryGate.subscribe, interruptedTurnRecoveryGate.getSnapshot, @@ -75,6 +89,7 @@ function LegacyPendingQueuePanel({ return isAcpFlowSession(FlowChatStore.getInstance().getState().sessions.get(sessionId)); }); useEffect(() => { + setExpanded(true); if (!sessionId) { setItems([]); return; @@ -191,8 +206,125 @@ function LegacyPendingQueuePanel({ return null; } + const renderItem = (item: QueuedMessage) => { + const isSendingNow = item.status === 'sending_now'; + const isSending = isSendingQueuedMessage(item); + const isFailed = isFailedQueuedMessage(item); + const previewText = item.displayMessage ?? item.content; + const attachmentCount = getQueuedMessageAttachmentCount(item); + const itemState: ChatComposerQueueItemState = isSending + ? 'sending' + : isFailed + ? 'failed' + : 'default'; + const itemClass = [ + 'openbitfun-pending-queue-panel__item', + isSending && 'openbitfun-pending-queue-panel__item--sending', + isFailed && 'openbitfun-pending-queue-panel__item--failed', + ] + .filter(Boolean) + .join(' '); + + return ( + + + {/* Focusable only so keyboard users can read a truncated preview; + it never starts editing. */} + + {previewText || t('pendingQueue.emptyPlaceholder')} + + {isSending ? ( + + {isSendingNow ? t('pendingQueue.statusSending') : t('pendingQueue.statusQueuedSending')} + + ) : isFailed ? ( + + {t('pendingQueue.statusFailed')} + + ) : null} + + + } + label={t('pendingQueue.attachmentCount', { count: attachmentCount })} + /> + + + + } + size="xs" + onClick={() => handleRestoreToComposer(item)} + /> + + + } + loading={isSendingNow} + size="xs" + onClick={() => { + void handleSendNow(item); + }} + /> + + + } + size="xs" + onClick={() => handleDelete(item)} + /> + + + + ); + }; + + // Collapsing hides the controlled list. Failed rows still need a decision, + // so they move to a recovery list outside the disclosure. + const recoveryItems = expanded ? [] : visibleItems.filter(isFailedQueuedMessage); + return ( - - ); } @@ -371,7 +382,7 @@ export function PendingQueuePanel(props: PendingQueuePanelProps): JSX.Element | const scope = getActiveSurfaceScope(); const { t } = useTranslation('flow-chat'); return <> - {supported && props.sessionId && pendingQueueManager.list(props.sessionId).length > 0 &&

{t('hostQueue.legacy')}

} + {supported && props.sessionId && pendingQueueManager.list(props.sessionId).length > 0 &&

{t('hostQueue.legacy')}

} {supported && props.sessionId && } ; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 43311b2eb8..6b6ac79a3d 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -3840,14 +3840,15 @@ "showAll": "Show all" }, "pendingQueue": { - "title": "Wait for sending", - "label": "Wait for sending {{count}}", + "title": "Queued messages", + "label": "Queued messages {{count}}", "attachmentCount": "{{count}} image attachment(s)", "attachmentCount_one": "{{count}} image attachment", "attachmentCount_other": "{{count}} image attachments", "emptyPlaceholder": "(empty message)", "statusSending": "Steering…", - "statusFailed": "Send failed — edit / send now / delete to clear", + "statusQueuedSending": "Sending…", + "statusFailed": "Send failed", "actions": { "edit": "Edit", "sendNow": "Send now (steer)", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 361fca49b7..65426cf62b 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -3840,14 +3840,15 @@ "showAll": "显示全部" }, "pendingQueue": { - "title": "等待发送", - "label": "等待发送 {{count}}", + "title": "待发送", + "label": "待发送 {{count}}", "attachmentCount": "{{count}} 个图片附件", "attachmentCount_one": "{{count}} 个图片附件", "attachmentCount_other": "{{count}} 个图片附件", "emptyPlaceholder": "(空消息)", "statusSending": "正在引导…", - "statusFailed": "发送失败 — 请编辑 / 立即发送 / 删除以清除该项", + "statusQueuedSending": "发送中…", + "statusFailed": "发送失败", "actions": { "edit": "编辑", "sendNow": "立即发送(引导)", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index d4b9319926..9e3cca7ba8 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -3840,14 +3840,15 @@ "showAll": "顯示全部" }, "pendingQueue": { - "title": "等待發送", - "label": "等待發送 {{count}}", + "title": "待傳送", + "label": "待傳送 {{count}}", "attachmentCount": "{{count}} 個圖片附件", "attachmentCount_one": "{{count}} 個圖片附件", "attachmentCount_other": "{{count}} 個圖片附件", "emptyPlaceholder": "(空訊息)", "statusSending": "正在引導…", - "statusFailed": "發送失敗 — 請編輯 / 立即發送 / 刪除以清除該項", + "statusQueuedSending": "傳送中…", + "statusFailed": "傳送失敗", "actions": { "edit": "編輯", "sendNow": "立即發送(引導)", From c6b1a10c2e0f536dcfba9bb46fbdd815c70ff94b Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 17:46:10 +0800 Subject: [PATCH 2/3] test(chat): make queue preview tooltip timing deterministic --- .../components/HostPendingQueuePanel.test.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx index 31804a2535..f04f310652 100644 --- a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx +++ b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx @@ -184,6 +184,13 @@ it('collapses the controlled list and keeps blocked and unconfirmed rows in a re }); it('lets keyboard users read a truncated preview without starting an edit', async () => { + vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); + const revealTooltip = () => { + act(() => vi.advanceTimersByTime(500)); + act(() => vi.advanceTimersByTime(20)); + }; const longText = 'Summarize the migration plan and list every follow-up owner'; const requests: string[] = []; const queue = new HostDialogQueue('scope', 'session', async request => { @@ -205,7 +212,7 @@ it('lets keyboard users read a truncated preview without starting an edit', asyn try { await act(async () => root.render()); await act(async () => { await queue.refresh(); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + act(() => vi.advanceTimersByTime(1)); const preview = container.querySelector('[data-openbitfun-product-part="preview"]')!; expect(preview.tabIndex).toBe(0); expect(preview.getAttribute('role')).toBeNull(); @@ -214,7 +221,7 @@ it('lets keyboard users read a truncated preview without starting an edit', asyn await act(async () => preview.focus()); expect(document.activeElement).toBe(preview); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 600)); }); + revealTooltip(); const tooltips = document.querySelectorAll('[role="tooltip"]'); expect(tooltips).toHaveLength(1); expect(tooltips[0].textContent).toBe(longText); @@ -232,14 +239,16 @@ it('lets keyboard users read a truncated preview without starting an edit', asyn // tooltip then shows alone instead of stacking with the preview's. const edit = container.querySelector('[aria-label="hostQueue.edit"]')!; await act(async () => edit.focus()); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 600)); }); + revealTooltip(); expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(0); await act(async () => { edit.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 600)); }); + revealTooltip(); expect([...document.querySelectorAll('[role="tooltip"]')].map(tooltip => tooltip.textContent)).toEqual(['hostQueue.edit']); } finally { - vi.restoreAllMocks(); await act(async () => root.unmount()); container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); } }); From 127c69929cf194d1134e6644091dd20ba70de4e7 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 18:22:36 +0800 Subject: [PATCH 3/3] feat(chat): style queued messages as an integrated composer tray --- .../composer/ChatComposerQueue.module.css | 78 +++++++-------- .../flow-chat/composer/ChatComposerQueue.tsx | 8 +- .../packages/ui/tests/chat-composer.test.mjs | 42 +++----- .../components/HostPendingQueuePanel.scss | 2 +- .../components/HostPendingQueuePanel.test.tsx | 48 ++++++---- .../components/HostPendingQueuePanel.tsx | 39 ++++---- .../components/PendingQueueMessageActions.tsx | 60 ++++++++++++ .../components/PendingQueuePanel.scss | 2 +- .../components/PendingQueuePanel.test.tsx | 95 +++++++++++-------- .../components/PendingQueuePanel.tsx | 72 ++++++-------- .../components/usePendingQueueDisclosure.ts | 26 +++++ src/web-ui/src/locales/en-US/flow-chat.json | 3 + src/web-ui/src/locales/zh-CN/flow-chat.json | 3 + src/web-ui/src/locales/zh-TW/flow-chat.json | 3 + 14 files changed, 295 insertions(+), 186 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/PendingQueueMessageActions.tsx create mode 100644 src/web-ui/src/flow_chat/components/usePendingQueueDisclosure.ts diff --git a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css index f6eea3ac32..9126e6cddc 100644 --- a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css +++ b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css @@ -7,7 +7,9 @@ flex-direction: column; gap: var(--openbitfun-space-1); padding: var(--openbitfun-space-1); - border-radius: var(--openbitfun-radius-lg); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-control-composer-surface-radius); + container: composer-queue / inline-size; color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-surface-subtle); font-family:var(--openbitfun-type-body-md-font-family); @@ -16,7 +18,11 @@ /* A composer with a context bar already tints the tray around its input. The queue joins that tray instead of stacking a second translucent card. */ :global([data-openbitfun-component="chat-composer"][data-has-context="true"]) .root { - padding-block-start: 0; + margin-block-end: calc(-1 * var(--openbitfun-space-2)); + padding-block-end: var(--openbitfun-space-3); + border-block-end: 0; + border-end-start-radius: 0; + border-end-end-radius: 0; background: transparent; } @@ -67,18 +73,15 @@ .list { display: flex; min-inline-size: 0; - /* Four standard message rows plus the three gaps between them. */ + /* Four comfortable message rows before the tray scrolls. */ max-block-size: calc( - var(--openbitfun-control-chat-composer-control-height) + - var(--openbitfun-control-chat-composer-control-height) + - var(--openbitfun-control-chat-composer-control-height) + - var(--openbitfun-control-chat-composer-control-height) + - var(--openbitfun-space-1) + - var(--openbitfun-space-1) + - var(--openbitfun-space-1) + var(--openbitfun-control-height-sm) + + var(--openbitfun-control-height-sm) + + var(--openbitfun-control-height-sm) + + var(--openbitfun-control-height-sm) ); flex-direction: column; - gap: var(--openbitfun-space-1); + gap: 0; margin: 0; overflow-x: hidden; overflow-y: auto; @@ -93,11 +96,10 @@ .item { box-sizing: border-box; - display: grid; + display: flex; flex: 0 0 auto; min-inline-size: 0; - min-block-size: var(--openbitfun-control-chat-composer-control-height); - grid-template-columns: minmax(0, 1fr) auto auto; + min-block-size: var(--openbitfun-control-height-sm); align-items: center; gap: var(--openbitfun-space-2); padding-inline: var(--openbitfun-space-2) var(--openbitfun-space-1); @@ -110,14 +112,23 @@ background: var(--openbitfun-color-surface-subtle); } + .leadingIcon { + display: inline-flex; + flex: 0 0 var(--openbitfun-space-5); + align-items: center; + justify-content: center; + color: var(--openbitfun-color-content-muted); + } + .content { display: flex; + flex: 1 1 0; min-inline-size: 0; flex-flow: row wrap; align-items: baseline; gap: 0 var(--openbitfun-space-2); - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-body-sm-font-size); + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-body-md-font-weight); line-height: var(--openbitfun-type-body-sm-line-height); } @@ -137,11 +148,6 @@ outline-offset: var(--openbitfun-focus-offset); } - .item:hover .content, - .item:focus-within .content { - color: var(--openbitfun-color-content-primary); - } - /* Muted ink instead of row opacity keeps the status text legible. */ .item[data-openbitfun-state="sending"] .content { color: var(--openbitfun-color-content-muted); @@ -183,31 +189,29 @@ gap: var(--openbitfun-space-1); } - /* Reveal is instant: this is a high-frequency pointer path. Rows that need a - decision or show progress keep their actions visible; focus reaches the rest. */ + /* The tray stays quiet without making its controls disappear. */ + .actions [data-openbitfun-component="button"][data-openbitfun-variant="text"] { + color: var(--openbitfun-color-content-secondary); + padding-inline: var(--openbitfun-space-2); + } + @media (hover: hover) and (pointer: fine) { .item:hover { background: var(--openbitfun-color-surface-subtle); } + } - .actions { - pointer-events: none; - opacity: 0; - } - - .item:hover .actions, - .item:focus-within .actions { - pointer-events: auto; - opacity: 1; - } - - .item:is([data-openbitfun-state="failed"], [data-openbitfun-state="sending"]) .actions { - pointer-events: auto; - opacity: 1; + @container composer-queue (max-width: 22rem) { + .actions [data-openbitfun-component="button"] [data-openbitfun-part="label"] { + display: none; } } @media (pointer: coarse) { + .actions [data-openbitfun-component="button"] { + min-block-size: var(--openbitfun-control-height-sm); + } + .actions [data-openbitfun-component="icon-button"] { inline-size: var(--openbitfun-control-height-sm); block-size: var(--openbitfun-control-height-sm); diff --git a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx index 8aae002e32..1652f06ff3 100644 --- a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx +++ b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx @@ -37,6 +37,7 @@ export interface ChatComposerQueueListProps export interface ChatComposerQueueItemProps extends LiHTMLAttributes { state?: ChatComposerQueueItemState; + leadingIcon?: ReactNode; } export interface ChatComposerQueueItemContentProps @@ -155,7 +156,7 @@ export const ChatComposerQueueList = forwardRef< export const ChatComposerQueueItem = forwardRef< HTMLLIElement, ChatComposerQueueItemProps ->(function ChatComposerQueueItem({ className, state = "default", ...props }, ref) { +>(function ChatComposerQueueItem({ children, className, leadingIcon, state = "default", ...props }, ref) { return (
  • + > + {leadingIcon && } + {children} +
  • ); }); diff --git a/design-system/packages/ui/tests/chat-composer.test.mjs b/design-system/packages/ui/tests/chat-composer.test.mjs index ca771e7a35..e64161b86e 100644 --- a/design-system/packages/ui/tests/chat-composer.test.mjs +++ b/design-system/packages/ui/tests/chat-composer.test.mjs @@ -102,7 +102,7 @@ test("ChatComposer exposes a neutral pending-message queue with per-message atta null, createElement( ChatComposerQueueItem, - { state: "default" }, + { state: "default", leadingIcon: createElement("svg", { "data-queue-mark": true }) }, createElement( ChatComposerQueueItemContent, null, @@ -138,6 +138,7 @@ test("ChatComposer exposes a neutral pending-message queue with per-message atta assert.ok(attachmentBadge); assert.match(attachmentBadge, /aria-label="3 image attachments"/); assert.match(markup, /aria-label="Send now"/); + assert.match(markup, /data-openbitfun-part="leading-icon" aria-hidden="true"> { @@ -188,44 +189,31 @@ test("ChatComposer queue keeps four message rows visible before scrolling", asyn const maxBlockSize = listRule.match(/max-block-size:\s*calc\((.*?)\);/s)?.[1]; assert.ok(maxBlockSize); assert.equal( - (maxBlockSize.match(/--openbitfun-control-chat-composer-control-height/g) ?? []).length, + (maxBlockSize.match(/--openbitfun-control-height-sm/g) ?? []).length, 4, ); - assert.equal((maxBlockSize.match(/--openbitfun-space-1/g) ?? []).length, 3); + assert.match(listRule, /gap:\s*0;/); + assert.match(styles, /\.item\s*\{[^}]*min-block-size:\s*var\(--openbitfun-control-height-sm\)/s); assert.match(listRule, /overflow-y:\s*auto/); assert.match(listRule, /overscroll-behavior-y:\s*contain/); }); -test("ChatComposer queue reveals row actions on hover and keyboard focus", async () => { - const styles = await readFile( - new URL("../src/flow-chat/composer/ChatComposerQueue.module.css", import.meta.url), - "utf8", - ); - - assert.match(styles, /@media \(hover: hover\) and \(pointer: fine\)/); - assert.match( - styles, - /\.actions\s*\{[^}]*pointer-events:\s*none;[^}]*opacity:\s*0;/s, - ); - assert.match( - styles, - /\.item:hover \.actions,\s*\.item:focus-within \.actions\s*\{[^}]*pointer-events:\s*auto;[^}]*opacity:\s*1;/s, - ); -}); - -test("ChatComposer queue promotes message text from secondary to primary on interaction", async () => { +test("ChatComposer queue keeps actions visible and message ink primary", async () => { const styles = await readFile( new URL("../src/flow-chat/composer/ChatComposerQueue.module.css", import.meta.url), "utf8", ); + const actionsRule = styles.match(/\.actions\s*\{[^}]*\}/s)?.[0]; const contentRule = styles.match(/\.content\s*\{[^}]*\}/s)?.[0]; - + assert.ok(actionsRule); assert.ok(contentRule); - assert.match(contentRule, /color:\s*var\(--openbitfun-color-content-secondary\)/); - assert.match( - styles, - /\.item:hover \.content,\s*\.item:focus-within \.content\s*\{[^}]*color:\s*var\(--openbitfun-color-content-primary\)/s, - ); + assert.match(actionsRule, /display:\s*inline-flex/); + assert.doesNotMatch(styles, /pointer-events:\s*none|opacity:\s*0(?:;|\s)/); + assert.match(contentRule, /color:\s*var\(--openbitfun-color-content-primary\)/); + assert.match(styles, /@media \(hover: hover\) and \(pointer: fine\)/); + assert.match(styles, /\.item:focus-within\s*\{[^}]*background:/s); + assert.match(styles, /@container composer-queue \(max-width: 22rem\)/); + assert.match(styles, /\[data-openbitfun-part="label"\]\s*\{\s*display: none;/); }); test("ChatComposer queue never changes the input surface geometry", async () => { diff --git a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.scss b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.scss index a0f7cbdc85..59012cc1b2 100644 --- a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.scss +++ b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.scss @@ -1,4 +1,4 @@ -// Geometry, rows, status tones and action reveal come from ChatComposerQueue. +// Geometry, rows, status tones and action layout come from ChatComposerQueue. // This file only lays out the host-specific help and error notes. .host-pending-queue { &__note, diff --git a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx index f04f310652..f273d4fdf1 100644 --- a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx +++ b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx @@ -8,6 +8,13 @@ import { HostPendingQueuePanel } from './HostPendingQueuePanel'; vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); vi.mock('../services/flow-chat-manager/PendingQueueModule', () => ({ pendingQueueManager: { enqueue: vi.fn() } })); +async function selectQueueMenuAction(container: HTMLElement, action: 'edit' | 'collapse') { + await act(async () => container.querySelector('[aria-label="pendingQueue.actions.more"]')!.click()); + const item = document.querySelector(`[role="menu"]:not([aria-hidden="true"]) [data-menu-id="${action}"]`); + expect(item).not.toBeNull(); + await act(async () => item!.click()); +} + it.each([false, true])('keeps a normal send hidden and shows recovery only on failure (failure=%s)', async failure => { const records = new Map(); let finish!: () => void; @@ -77,8 +84,9 @@ it('keeps four queued messages compact, exposes actions, and toggles help indepe expect(container.textContent).not.toContain('hostQueue.memoryNotice'); expect(container.textContent).not.toContain('hostQueue.queued'); expect(container.querySelectorAll('[aria-label="hostQueue.sendNow"]')).toHaveLength(4); + expect(container.querySelector('[data-openbitfun-part="header"]')).toBeNull(); + await selectQueueMenuAction(container, 'collapse'); const toggle = container.querySelector('.host-pending-queue__toggle')!; - await act(async () => toggle.click()); expect(container.querySelector('ul')?.hidden).toBe(true); await act(async () => container.querySelector('[aria-label="hostQueue.about"]')!.click()); expect(container.textContent).toContain('hostQueue.memoryNotice'); @@ -147,22 +155,21 @@ it('collapses the controlled list and keeps blocked and unconfirmed rows in a re try { await act(async () => root.render( true} />)); await act(async () => { await queue.refresh(); }); - const toggle = container.querySelector('.host-pending-queue__toggle')!; - const controlled = () => document.getElementById(toggle.getAttribute('aria-controls')!)!; + const controlled = container.querySelector('ul')!; const count = () => container.querySelector('[data-openbitfun-part="totalCount"]')?.textContent; - expect(toggle.getAttribute('aria-expanded')).toBe('true'); - expect(controlled().hidden).toBe(false); - expect([...controlled().querySelectorAll('li')].map(row => row.textContent)).toEqual([ + expect(controlled.hidden).toBe(false); + expect([...controlled.querySelectorAll('li')].map(row => row.textContent)).toEqual([ expect.stringContaining('message a'), expect.stringContaining('message b'), expect.stringContaining('lost prompt'), ]); - expect(count()).toBe('3'); - - await act(async () => toggle.click()); + await selectQueueMenuAction(container, 'collapse'); + const toggle = container.querySelector('.host-pending-queue__toggle')!; + expect(toggle.getAttribute('aria-controls')).toBe(controlled.id); + expect(document.activeElement).toBe(toggle); expect(toggle.getAttribute('aria-expanded')).toBe('false'); - expect(controlled().hidden).toBe(true); - expect(controlled().querySelectorAll('li')).toHaveLength(0); + expect(controlled.hidden).toBe(true); + expect(controlled.querySelectorAll('li')).toHaveLength(0); expect(count()).toBe('3'); - const recovery = [...container.querySelectorAll('ul')].filter(list => list !== controlled()); + const recovery = [...container.querySelectorAll('ul')].filter(list => list !== controlled); expect(recovery).toHaveLength(1); expect(recovery[0].hidden).toBe(false); const rows = recovery[0].querySelectorAll('li'); @@ -172,11 +179,16 @@ it('collapses the controlled list and keeps blocked and unconfirmed rows in a re expect(rows[0].querySelector('[aria-label="hostQueue.cancel"]')).not.toBeNull(); expect(rows[1].querySelector('[aria-label="hostQueue.checkRetry"]')).not.toBeNull(); expect(container.textContent).not.toContain('message a'); + await act(async () => rows[0].querySelector('[aria-label="pendingQueue.actions.more"]')!.click()); + expect(document.querySelector('[role="menu"]:not([aria-hidden="true"]) [data-menu-id="collapse"]')).toBeNull(); + await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + await act(async () => toggle.click()); - expect(controlled().hidden).toBe(false); - expect(controlled().querySelectorAll('li')).toHaveLength(3); + expect(controlled.hidden).toBe(false); + expect(controlled.querySelectorAll('li')).toHaveLength(3); expect(container.querySelectorAll('ul')).toHaveLength(1); + expect(document.activeElement).toBe(controlled.querySelector('[data-openbitfun-product-part="preview"]')); } finally { await act(async () => root.unmount()); container.remove(); @@ -237,13 +249,13 @@ it('lets keyboard users read a truncated preview without starting an edit', asyn // Leaving the preview dismisses its tooltip; an action's own (hover) // tooltip then shows alone instead of stacking with the preview's. - const edit = container.querySelector('[aria-label="hostQueue.edit"]')!; - await act(async () => edit.focus()); + const send = container.querySelector('[aria-label="hostQueue.sendNow"]')!; + await act(async () => send.focus()); revealTooltip(); expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(0); - await act(async () => { edit.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + await act(async () => { send.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); revealTooltip(); - expect([...document.querySelectorAll('[role="tooltip"]')].map(tooltip => tooltip.textContent)).toEqual(['hostQueue.edit']); + expect([...document.querySelectorAll('[role="tooltip"]')].map(tooltip => tooltip.textContent)).toEqual(['hostQueue.sendNow']); } finally { await act(async () => root.unmount()); container.remove(); diff --git a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx index c99bc3b30e..16f028cc81 100644 --- a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx +++ b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx @@ -3,8 +3,10 @@ import type { QueuedMessage } from '../types/flow-chat'; import { useEffect, useId, useState, useSyncExternalStore, type ReactNode } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { Button, Icon, IconButton, OverflowText, Tooltip } from '@openbitfun/ui'; -import { Paperclip, RotateCw } from 'lucide-react'; +import { ListEnd, Paperclip, RotateCw } from 'lucide-react'; import './HostPendingQueuePanel.scss'; +import { PendingQueueMessageActions } from './PendingQueueMessageActions'; +import { usePendingQueueDisclosure } from './usePendingQueueDisclosure'; import { hostQueuePresentation, observePresentedHostQueue } from '../services/hostQueuePresentation'; import { HostDialogQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue'; import { @@ -24,7 +26,7 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ const view = useSyncExternalStore(queue.subscribe, queue.getSnapshot, queue.getSnapshot); const presentation = hostQueuePresentation(queue); const projected = useSyncExternalStore(presentation.subscribe, presentation.getSnapshot, presentation.getSnapshot); - const [expanded, setExpanded] = useState(true); + const { expanded, toggle, toggleRef, listRef } = usePendingQueueDisclosure(); const [showHelp, setShowHelp] = useState(false); const listId = useId(); const [busy, setBusy] = useState(false); @@ -69,7 +71,7 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ const steering = item.status === 'steering_pending'; const blocked = item.status === 'blocked'; return + leadingIcon={} state={blocked ? 'failed' : steering ? 'sending' : 'default'}> {renderPreview(item.displayContent || t('pendingQueue.emptyPlaceholder'))} {(blocked || steering) && @@ -80,19 +82,22 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ } label={t('hostQueue.attachments', { count: item.attachmentCount })} /> - } disabled={locked || steering} 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); - })} /> - } disabled={locked || steering} onClick={() => void run(() => queue.act(item, 'promote'))} /> - } disabled={locked || steering} onClick={() => void run(() => queue.act(item, 'cancel'))} /> + void run(() => queue.act(item, 'promote'))} + editLabel={t('hostQueue.edit')} editDisabled={locked || steering} onEdit={() => 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); + })} + removeLabel={t('hostQueue.cancel')} removeDisabled={locked || steering} + onRemove={() => void run(() => queue.act(item, 'cancel'))} + onCollapse={expanded ? toggle : undefined} onAbout={() => setShowHelp(value => !value)} /> ; }; const renderPending = (record: QueueOutboxRecord) => { const message = record.request.action === 'submit' ? record.request.message : null; - return + return } state="failed"> {message && renderPreview(message.displayContent ?? message.content)} {t('hostQueue.unknown')} @@ -119,21 +124,21 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ // (blocked or unconfirmed) move to a recovery list outside the disclosure. const recoveryItems = expanded ? [] : items.filter(item => item.status === 'blocked'); const recoveryPending = expanded ? [] : view.pending; - return - - setExpanded(value => !value)}> + return + {(!expanded || items.length === 0) && + {t('hostQueue.title')} } onClick={() => setShowHelp(value => !value)} /> - + } {showHelp &&

    {t('hostQueue.memoryNotice')}

    } {visibleError &&
    {visibleError}
    } -