diff --git a/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx b/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx index fefa250fed..e9999508c5 100644 --- a/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx +++ b/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx @@ -23,11 +23,13 @@ import { Image as ImageIcon, Info, Layers, + ListEnd, ListTodo, MessageSquare, Mic, Monitor, Plus, + Pencil, Rocket, Search, SearchCheck, @@ -35,9 +37,11 @@ import { SquareTerminal, Terminal, Timer, + Trash2, Zap, type LucideIcon, } from "lucide-react"; +import { IconButton } from "@bitfun/ui"; import { AgentControlToolCard, AgentWaitToolCard, @@ -45,6 +49,14 @@ import { AmbientToolCardHeader, AskUser, ChatComposer, + ChatComposerQueue, + ChatComposerQueueAttachmentBadge, + ChatComposerQueueHeader, + ChatComposerQueueItem, + ChatComposerQueueItemActions, + ChatComposerQueueItemContent, + ChatComposerQueueList, + ChatComposerQueueTitle, CommandToolCard, ContextCompressionToolCard, DefaultToolCard, @@ -122,6 +134,7 @@ type PreviewProps = FlowChatPreviewRenderOptions; function ChatComposerPreview({ interactive, state }: PreviewProps) { const [value, setValue] = useState(""); const expanded = state === "expanded"; + const queued = state === "queued"; return (
@@ -168,6 +181,44 @@ function ChatComposerPreview({ interactive, state }: PreviewProps) { )} layout={expanded ? "expanded" : "compact"} + queue={queued ? ( + + + + + + + Help me turn these two photos into Studio Ghibli style. Wait, maybe… + + + + } + size="xs" + /> + } + size="xs" + /> + } + size="xs" + /> + + + + + ) : undefined} startActions={( , })); @@ -62,6 +65,8 @@ vi.mock('../services/flow-chat-manager/PendingQueueModule', () => ({ 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' }], }, ], subscribe: () => () => undefined, @@ -127,6 +132,24 @@ describe('PendingQueuePanel', () => { vi.clearAllMocks(); }); + it('shows the queue total separately from the current message attachment count', async () => { + await act(async () => { + root.render( + true} + />, + ); + }); + + const title = container.querySelector('[data-bf-part="title"]'); + const attachmentBadge = container.querySelector('[data-bf-part="attachmentCount"]'); + + expect(title?.textContent).toBe('pendingQueue.title1'); + expect(attachmentBadge?.textContent).toBe('3'); + expect(attachmentBadge?.getAttribute('aria-label')).toBe('pendingQueue.attachmentCount'); + }); + it('submits only one steering request while send-now is in flight', async () => { let resolveSteering: ((value: { steeringId: string }) => void) | undefined; mocks.steerDialogTurn.mockImplementation( @@ -136,7 +159,12 @@ describe('PendingQueuePanel', () => { ); await act(async () => { - root.render(); + root.render( + true} + />, + ); }); const sendNowButton = container.querySelector( 'button[aria-label="pendingQueue.actions.sendNow"]', @@ -156,4 +184,73 @@ describe('PendingQueuePanel', () => { await Promise.resolve(); }); }); + + it('keeps the message item display-only', async () => { + const onRestoreToComposer = vi.fn(() => true); + await act(async () => { + root.render( + , + ); + }); + + const preview = container.querySelector( + '.bitfun-pending-queue-panel__preview', + ); + expect(preview).not.toBeNull(); + expect(preview?.getAttribute('role')).toBeNull(); + expect(preview?.getAttribute('tabindex')).toBeNull(); + + act(() => preview!.click()); + + expect(onRestoreToComposer).not.toHaveBeenCalled(); + expect(mocks.queueRemove).not.toHaveBeenCalled(); + }); + + it('restores from the edit action and removes only an accepted draft', async () => { + const onRestoreToComposer = vi.fn(() => true); + await act(async () => { + root.render( + , + ); + }); + + const editButton = container.querySelector( + 'button[aria-label="pendingQueue.actions.edit"]', + ); + expect(editButton).not.toBeNull(); + + act(() => editButton!.click()); + + expect(onRestoreToComposer).toHaveBeenCalledWith(expect.objectContaining({ + id: 'queued-1', + content: 'steer this turn', + })); + expect(mocks.queueRemove).toHaveBeenCalledWith('session-1', 'queued-1'); + }); + + it('keeps the queued item when ChatInput rejects restoration', async () => { + const onRestoreToComposer = vi.fn(() => false); + await act(async () => { + root.render( + , + ); + }); + + const editButton = container.querySelector( + 'button[aria-label="pendingQueue.actions.edit"]', + ); + act(() => editButton!.click()); + + expect(onRestoreToComposer).toHaveBeenCalledTimes(1); + expect(mocks.queueRemove).not.toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx index bac2c462a0..7ed172c8bf 100644 --- a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx @@ -2,11 +2,13 @@ * Pending queue panel * * Renders the per-session list of "queued" user messages above the chat input. - * Each card supports inline edit, optional "send now" (mid-turn steering), and delete. + * Each row supports restoring its draft to ChatInput, optional "send now" + * (mid-turn steering), and delete. * * UX notes: - * - Click anywhere on the preview text to start editing. - * - Cmd/Ctrl+Enter saves the edit; Esc cancels. + * - Message content is display-only; editing starts only from the edit action. + * - ChatInput owns restoration and overwrite protection. The queue item is + * removed only after ChatInput accepts the draft. * - Clicking "send now" eagerly inserts a steering message into the live round * so the user sees feedback instantly; the backend confirmation event is * deduped via `steeringId`. @@ -18,10 +20,7 @@ import { Pencil, ArrowUp, Trash2, - Check, - X as XIcon, - Inbox, - Loader2, + ListEnd, } from 'lucide-react'; import { Tooltip } from '@/component-library'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; @@ -33,22 +32,37 @@ import { interruptedTurnRecoveryGate } from '../services/interruptedTurnRecovery import { insertSteeringItemIfAbsent } from '../services/flow-chat-manager/EventHandlerModule'; import { notificationService } from '../../shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; -import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; import type { QueuedMessage, SteeringImage } from '../types/flow-chat'; import { isAcpFlowSession } from '../utils/acpSession'; +import { getQueuedMessageAttachmentCount } from '../utils/pendingQueuePresentation'; import './PendingQueuePanel.scss'; import { IconButton } from '@bitfun/ui'; +import { + ChatComposerQueue, + ChatComposerQueueAttachmentBadge, + ChatComposerQueueHeader, + ChatComposerQueueItem, + ChatComposerQueueItemActions, + ChatComposerQueueItemContent, + ChatComposerQueueList, + ChatComposerQueueTitle, + type ChatComposerQueueItemState, +} from '@bitfun/ui/flow-chat'; const log = createLogger('PendingQueuePanel'); interface PendingQueuePanelProps { sessionId: string | undefined; className?: string; + onRestoreToComposer: (item: QueuedMessage) => boolean; } -export function PendingQueuePanel({ sessionId, className }: PendingQueuePanelProps): JSX.Element | null { +export function PendingQueuePanel({ + sessionId, + className, + onRestoreToComposer, +}: PendingQueuePanelProps): JSX.Element | null { const { t } = useTranslation('flow-chat'); - const editorCompositionActiveRef = useRef(false); const sendNowInFlightIdsRef = useRef(new Set()); useSyncExternalStore( interruptedTurnRecoveryGate.subscribe, @@ -63,9 +77,6 @@ export function PendingQueuePanel({ sessionId, className }: PendingQueuePanelPro if (!sessionId) return false; return isAcpFlowSession(FlowChatStore.getInstance().getState().sessions.get(sessionId)); }); - const [editingId, setEditingId] = useState(null); - const [editingDraft, setEditingDraft] = useState(''); - useEffect(() => { if (!sessionId) { setItems([]); @@ -94,32 +105,14 @@ export function PendingQueuePanel({ sessionId, className }: PendingQueuePanelPro return unsubscribe; }, [sessionId]); - const handleEditStart = useCallback((item: QueuedMessage) => { - setEditingId(item.id); - setEditingDraft(item.displayMessage ?? item.content); - }, []); - - const handleEditCancel = useCallback(() => { - setEditingId(null); - setEditingDraft(''); - }, []); - - const handleEditSave = useCallback( + const handleRestoreToComposer = useCallback( (item: QueuedMessage) => { if (!sessionId) return; - const trimmed = editingDraft.trim(); - if (!trimmed) { - notificationService.warning(t('pendingQueue.errors.emptyContent'), { duration: 3000 }); - return; + if (onRestoreToComposer(item)) { + pendingQueueManager.remove(sessionId, item.id); } - pendingQueueManager.update(sessionId, item.id, { - content: trimmed, - displayMessage: trimmed, - }); - setEditingId(null); - setEditingDraft(''); }, - [editingDraft, sessionId, t], + [onRestoreToComposer, sessionId], ); const handleDelete = useCallback( @@ -216,88 +209,72 @@ export function PendingQueuePanel({ sessionId, className }: PendingQueuePanelPro } return ( -
{ e.stopPropagation(); }} > -
- - - {t('pendingQueue.title', { count: visibleItems.length })} - - {' · '} - {t('pendingQueue.hint')} - - -
-
    + + + {visibleItems.map(item => { - const isEditing = editingId === item.id; const isSendingNow = item.status === 'sending_now'; const isSending = item.status === 'sending' || isSendingNow; const isFailed = item.status === 'failed' || (item.retryCount ?? 0) > 0; const previewText = item.displayMessage ?? item.content; + const attachmentCount = getQueuedMessageAttachmentCount(item); + const itemState: ChatComposerQueueItemState = isSending + ? 'sending' + : isFailed + ? 'failed' + : 'default'; const itemClass = [ 'bitfun-pending-queue-panel__item', - isEditing && 'bitfun-pending-queue-panel__item--editing', isSending && 'bitfun-pending-queue-panel__item--sending', isFailed && 'bitfun-pending-queue-panel__item--failed', ] .filter(Boolean) .join(' '); + return ( -
  • -
    - {isEditing ? ( - <> -