From 069fd2bcce5e89664b703df76dcc61c2a018fabd Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 19:26:54 +0800 Subject: [PATCH] fix(chat): align queue actions and keep attachments beside messages --- design-system/packages/ui/README.md | 2 + .../composer/ChatComposerQueue.module.css | 59 ++++++++++++++----- .../flow-chat/composer/ChatComposerQueue.tsx | 19 ++++++ .../ui/src/flow-chat/composer/index.ts | 2 + .../packages/ui/tests/chat-composer.test.mjs | 29 +++++++-- .../components/HostPendingQueuePanel.test.tsx | 8 ++- .../components/HostPendingQueuePanel.tsx | 30 ++++++---- .../components/PendingQueuePanel.test.tsx | 4 ++ .../components/PendingQueuePanel.tsx | 38 ++++++------ src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 12 files changed, 144 insertions(+), 50 deletions(-) diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index ac82ee490a..6959ac5c8e 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -959,6 +959,8 @@ KeyHint uses 10px text on a 10px line and 2px block padding. StatusPill uses a 1 Composer and ChatComposer share the 16px surface radius, 8px padding, 12px content gap, composer border, context tint, and composer shadow. Compact ChatComposer uses a 24px action track plus 8px padding on each side and two 1px borders (42px border-box height); expanded height follows editor content. The generic context shell has no duplicate outer border or surface inset. Queue and editor state remain owned by their existing slots and product adapters. +Place a queued message preview and its `ChatComposerQueueAttachmentBadge` inside `ChatComposerQueueItemMessage`, within `ChatComposerQueueItemContent`. This keeps short text and attachments together while long previews shrink. Status descriptions remain siblings below the message line. `ChatComposerQueueItemActions` reserves consistent action widths and aligns controls to that first line; narrow trays keep icon actions accessible. + ActivityItem `surface` uses a 40px minimum row with a centered outline, 12px radius, 9px block padding, 12px leading inset and 9px trailing inset. The 22px sibling actions fit inside that row; optional detail grows beneath it. Inline 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 9126e6cddc..8efefc5a28 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 @@ -1,5 +1,7 @@ @layer openbitfun.components { .root { + --_queue-action-size: var(--openbitfun-control-chat-composer-control-height); + --_queue-primary-action-size: 8em; box-sizing: border-box; display: flex; min-inline-size: 0; @@ -94,13 +96,18 @@ display: none; } + /* Recovery descriptions need room below the message baseline. */ + .list:has(.status) { + max-block-size: calc(var(--openbitfun-control-height-sm) * 6); + } + .item { box-sizing: border-box; display: flex; flex: 0 0 auto; min-inline-size: 0; min-block-size: var(--openbitfun-control-height-sm); - align-items: center; + align-items: flex-start; gap: var(--openbitfun-space-2); padding-inline: var(--openbitfun-space-2) var(--openbitfun-space-1); border-radius: var(--openbitfun-radius-md); @@ -115,6 +122,7 @@ .leadingIcon { display: inline-flex; flex: 0 0 var(--openbitfun-space-5); + min-block-size: var(--openbitfun-control-height-sm); align-items: center; justify-content: center; color: var(--openbitfun-color-content-muted); @@ -124,25 +132,34 @@ display: flex; flex: 1 1 0; min-inline-size: 0; + min-block-size: var(--openbitfun-control-height-sm); flex-flow: row wrap; align-items: baseline; gap: 0 var(--openbitfun-space-2); 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); + line-height: var(--openbitfun-type-body-md-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; + .message { + display: flex; + flex: 1 0 100%; min-inline-size: 0; + max-inline-size: 100%; + min-block-size: var(--openbitfun-control-height-sm); + align-items: center; + gap: var(--openbitfun-space-2); + } + + /* Shrink long text, but never stretch a short preview away from its badge. */ + .message > :global([data-overflow-behavior]) { + flex: 0 1 auto; } /* 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 { + :where(.content, .message) > :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); @@ -160,6 +177,7 @@ font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-body-sm-line-height); overflow-wrap: anywhere; + padding-block-end: var(--openbitfun-space-1); } .status[data-openbitfun-tone="warning"] { @@ -185,12 +203,26 @@ display: inline-flex; flex: 0 0 auto; min-inline-size: 0; + inline-size: calc(var(--_queue-primary-action-size) + var(--_queue-action-size) * 2 + var(--openbitfun-space-1) * 2); + min-block-size: var(--openbitfun-control-height-sm); align-items: center; + justify-content: flex-end; gap: var(--openbitfun-space-1); + font-size: var(--openbitfun-type-body-sm-font-size); + } + + .actions [data-openbitfun-component="icon-button"] { + flex: 0 0 var(--_queue-action-size); + inline-size: var(--_queue-action-size); + block-size: var(--_queue-action-size); + min-inline-size: var(--_queue-action-size); + min-block-size: var(--_queue-action-size); } /* The tray stays quiet without making its controls disappear. */ .actions [data-openbitfun-component="button"][data-openbitfun-variant="text"] { + flex: 1 1 0; + min-inline-size: 0; color: var(--openbitfun-color-content-secondary); padding-inline: var(--openbitfun-space-2); } @@ -202,20 +234,19 @@ } @container composer-queue (max-width: 22rem) { + .actions { + --_queue-primary-action-size: var(--_queue-action-size); + } .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); + .root { + --_queue-action-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); - min-inline-size: var(--openbitfun-control-height-sm); + .actions [data-openbitfun-component="button"] { min-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 1652f06ff3..925f55d0eb 100644 --- a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx +++ b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.tsx @@ -43,6 +43,9 @@ export interface ChatComposerQueueItemProps export interface ChatComposerQueueItemContentProps extends HTMLAttributes {} +export interface ChatComposerQueueItemMessageProps + extends HTMLAttributes {} + export type ChatComposerQueueItemStatusTone = "neutral" | "warning" | "danger"; export interface ChatComposerQueueItemStatusProps @@ -187,6 +190,22 @@ export const ChatComposerQueueItemContent = forwardRef< ); }); +/** Natural-width preview and its attachments; status belongs below this line. */ +export const ChatComposerQueueItemMessage = forwardRef< + HTMLDivElement, + ChatComposerQueueItemMessageProps +>(function ChatComposerQueueItemMessage({ className, ...props }, ref) { + return ( +
+ ); +}); + /** A fact about one message, such as its delivery state or a failure reason. */ export const ChatComposerQueueItemStatus = forwardRef< HTMLSpanElement, diff --git a/design-system/packages/ui/src/flow-chat/composer/index.ts b/design-system/packages/ui/src/flow-chat/composer/index.ts index 657ba334d4..b5e17cb096 100644 --- a/design-system/packages/ui/src/flow-chat/composer/index.ts +++ b/design-system/packages/ui/src/flow-chat/composer/index.ts @@ -16,6 +16,7 @@ export { ChatComposerQueueItem, ChatComposerQueueItemActions, ChatComposerQueueItemContent, + ChatComposerQueueItemMessage, ChatComposerQueueItemStatus, ChatComposerQueueList, ChatComposerQueueTitle, @@ -24,6 +25,7 @@ export { type ChatComposerQueueHeaderProps, type ChatComposerQueueItemActionsProps, type ChatComposerQueueItemContentProps, + type ChatComposerQueueItemMessageProps, type ChatComposerQueueItemProps, type ChatComposerQueueItemState, type ChatComposerQueueItemStatusProps, diff --git a/design-system/packages/ui/tests/chat-composer.test.mjs b/design-system/packages/ui/tests/chat-composer.test.mjs index e64161b86e..00031e7d16 100644 --- a/design-system/packages/ui/tests/chat-composer.test.mjs +++ b/design-system/packages/ui/tests/chat-composer.test.mjs @@ -14,6 +14,7 @@ import { ChatComposerQueueItem, ChatComposerQueueItemActions, ChatComposerQueueItemContent, + ChatComposerQueueItemMessage, ChatComposerQueueItemStatus, ChatComposerQueueList, ChatComposerQueueTitle, @@ -106,12 +107,16 @@ test("ChatComposer exposes a neutral pending-message queue with per-message atta createElement( ChatComposerQueueItemContent, null, - "Help me turn these photos into a painted scene.", + createElement( + ChatComposerQueueItemMessage, + null, + createElement("span", { "data-overflow-behavior": "fade" }, "Help me turn these photos into a painted scene."), + createElement(ChatComposerQueueAttachmentBadge, { + count: 3, + label: "3 image attachments", + }), + ), ), - createElement(ChatComposerQueueAttachmentBadge, { - count: 3, - label: "3 image attachments", - }), createElement( ChatComposerQueueItemActions, null, @@ -216,6 +221,20 @@ test("ChatComposer queue keeps actions visible and message ink primary", async ( assert.match(styles, /\[data-openbitfun-part="label"\]\s*\{\s*display: none;/); }); +test("ChatComposer queue groups attachments with natural-width text and anchors actions to the first line", async () => { + const styles = await readFile( + new URL("../src/flow-chat/composer/ChatComposerQueue.module.css", import.meta.url), + "utf8", + ); + assert.match(styles, /\.message\s*\{[^}]*display:\s*flex;[^}]*min-inline-size:\s*0;[^}]*align-items:\s*center;/s); + assert.match(styles, /\.message > :global\(\[data-overflow-behavior\]\)\s*\{[^}]*flex:\s*0 1 auto;/s); + assert.match(styles, /\.attachmentBadge\s*\{[^}]*flex:\s*0 0 auto;/s); + assert.match(styles, /\.item\s*\{[^}]*align-items:\s*flex-start;/s); + assert.match(styles, /\.actions\s*\{[^}]*inline-size:\s*calc\(var\(--_queue-primary-action-size\)/s); + assert.match(styles, /\.actions\s*\{[^}]*min-block-size:\s*var\(--openbitfun-control-height-sm\)/s); + assert.match(styles, /\.list:has\(\.status\)\s*\{[^}]*max-block-size:/s); +}); + test("ChatComposer queue never changes the input surface geometry", async () => { const styles = await readFile( new URL("../src/flow-chat/composer/ChatComposer.module.css", import.meta.url), 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 f273d4fdf1..966b41c9ad 100644 --- a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx +++ b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.test.tsx @@ -67,7 +67,7 @@ it.each([false, true])('keeps a normal send hidden and shows recovery only on fa 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', + status: 'queued' as const, previewTruncated: false, attachmentCount: index === 0 ? 2 : 0, agentType: 'Standard', createdAtMs: 1, reason: null, targetTurnId: null, steeringId: null })); const queue = new HostDialogQueue('scope', 'session', async request => { requests.push(request.action); @@ -81,6 +81,9 @@ it('keeps four queued messages compact, exposes actions, and toggles help indepe try { await act(async () => root.render( true} />)); expect(container.querySelectorAll('li')).toHaveLength(4); + const badge = container.querySelector('[data-openbitfun-part="attachmentCount"]')!; + expect(badge.closest('[data-openbitfun-part="message"]')?.querySelector('[data-openbitfun-product-part="preview"]')?.textContent).toBe('1'); + expect(badge.previousElementSibling?.getAttribute('data-openbitfun-product-part')).toBe('preview'); expect(container.textContent).not.toContain('hostQueue.memoryNotice'); expect(container.textContent).not.toContain('hostQueue.queued'); expect(container.querySelectorAll('[aria-label="hostQueue.sendNow"]')).toHaveLength(4); @@ -177,7 +180,8 @@ it('collapses the controlled list and keeps blocked and unconfirmed rows in a re expect.stringContaining('hostQueue.blocked'), expect.stringContaining('hostQueue.unknown'), ]); expect(rows[0].querySelector('[aria-label="hostQueue.cancel"]')).not.toBeNull(); - expect(rows[1].querySelector('[aria-label="hostQueue.checkRetry"]')).not.toBeNull(); + expect(rows[1].querySelector('[aria-label="hostQueue.checkRetry"]')?.textContent).toContain('hostQueue.retry'); + expect(rows[1].querySelector('[data-openbitfun-part="message"] [data-openbitfun-part="status"]')).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(); diff --git a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx index 16f028cc81..741bdbdf24 100644 --- a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx +++ b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx @@ -11,13 +11,13 @@ import { hostQueuePresentation, observePresentedHostQueue } from '../services/ho import { HostDialogQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue'; import { ChatComposerQueue, ChatComposerQueueHeader, ChatComposerQueueTitle, ChatComposerQueueToggle, - ChatComposerQueueList, ChatComposerQueueItem, ChatComposerQueueItemContent, ChatComposerQueueItemActions, + ChatComposerQueueList, ChatComposerQueueItem, ChatComposerQueueItemContent, ChatComposerQueueItemMessage, ChatComposerQueueItemActions, ChatComposerQueueItemStatus, ChatComposerQueueAttachmentBadge, } from '@openbitfun/ui/flow-chat'; function QueueAction({ label, icon, disabled, onClick }: { label: string; icon: ReactNode; disabled: boolean; onClick: () => void }) { return - + ; } @@ -73,14 +73,16 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ return } state={blocked ? 'failed' : steering ? 'sending' : 'default'}> - {renderPreview(item.displayContent || t('pendingQueue.emptyPlaceholder'))} + + {renderPreview(item.displayContent || t('pendingQueue.emptyPlaceholder'))} + } + label={t('hostQueue.attachments', { count: item.attachmentCount })} /> + {(blocked || steering) && {blocked ? t('hostQueue.blocked') : t('hostQueue.steeringPending')} } {item.reason && {item.reason}} - } - label={t('hostQueue.attachments', { count: item.attachmentCount })} /> void run(() => queue.act(item, 'promote'))} @@ -99,14 +101,20 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ const message = record.request.action === 'submit' ? record.request.message : null; return } state="failed"> - {message && renderPreview(message.displayContent ?? message.content)} + {message && {renderPreview(message.displayContent ?? message.content)}} {t('hostQueue.unknown')} - {record.restoreIntent && message - ? } disabled={busy} onClick={() => void run(() => restoreDraft(record))} /> - : } disabled={busy} onClick={() => void run(() => queue.retry(record))} />} - {message && } disabled={busy} onClick={() => { + + + + {message && } disabled={busy} onClick={() => { const cache = record.draft as Partial | undefined; // Copy locally even after an owner restart. Keep the unresolved // receipt: copying is neither a cancellation nor a resend. @@ -116,7 +124,7 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ status: 'queued', retryCount: cache?.retryCount ?? 0, userMessageMetadata: message.metadata }); }} />} - } disabled={busy} onClick={() => void run(() => queue.dismiss(record))} /> + } disabled={busy} onClick={() => void run(() => queue.dismiss(record))} /> ; }; 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 3bd24f7946..1c39ec55e4 100644 --- a/src/web-ui/src/flow_chat/components/PendingQueuePanel.test.tsx +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.test.tsx @@ -147,6 +147,9 @@ describe('PendingQueuePanel', () => { const attachmentBadge = container.querySelector('[data-openbitfun-part="attachmentCount"]'); expect(attachmentBadge?.textContent).toBe('3'); + const messageLine = attachmentBadge?.closest('[data-openbitfun-part="message"]'); + expect(messageLine?.querySelector('[data-openbitfun-product-part="preview"]')?.textContent).toBe('steer this turn'); + expect(attachmentBadge?.previousElementSibling?.getAttribute('data-openbitfun-product-part')).toBe('preview'); expect(attachmentBadge?.getAttribute('role')).toBe('img'); expect(attachmentBadge?.getAttribute('aria-label')).toBe('pendingQueue.attachmentCount'); await selectQueueMenuAction(container, 'collapse'); @@ -325,6 +328,7 @@ describe('PendingQueuePanel', () => { const row = container.querySelector('li')!; expect(row.getAttribute('data-openbitfun-state')).toBe('sending'); expect(row.textContent).toContain('pendingQueue.statusQueuedSending'); + expect(row.querySelector('[data-openbitfun-part="message"] [data-openbitfun-part="status"]')).toBeNull(); expect(row.textContent).not.toContain('pendingQueue.statusFailed'); expect(row.querySelector('[data-openbitfun-tone="danger"]')).toBeNull(); expect(row.querySelector('[aria-label="pendingQueue.actions.sendNow"]')?.disabled).toBe(true); diff --git a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx index 25e21e7b83..90dd56972c 100644 --- a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx @@ -43,6 +43,7 @@ import { ChatComposerQueueItem, ChatComposerQueueItemActions, ChatComposerQueueItemContent, + ChatComposerQueueItemMessage, ChatComposerQueueItemStatus, ChatComposerQueueList, ChatComposerQueueTitle, @@ -241,18 +242,25 @@ function LegacyPendingQueuePanel({ data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="content" > - {/* Focusable only so keyboard users can read a truncated preview; - it never starts editing. */} - - {previewText || t('pendingQueue.emptyPlaceholder')} - + + {/* Focusable only so keyboard users can read a truncated preview; + it never starts editing. */} + + {previewText || t('pendingQueue.emptyPlaceholder')} + + } + label={t('pendingQueue.attachmentCount', { count: attachmentCount })} + /> + {isSending ? ( - } - label={t('pendingQueue.attachmentCount', { count: attachmentCount })} - /> -